八、高级异步
既然我们已经决定 asyncio 是一种适用于我们的聚合流程的技术,我们需要确保我们工作的代码是生产质量的。到目前为止,我们已经忽略了 apd.aggregation 代码库中的任何测试;是时候解决这个问题了,还有我们在前一章中顺便提到的阻塞数据库集成的问题。
测试异步代码
我们可以使用现有的工具来测试我们的异步代码,但是我们需要做一些小的调整来设置异步环境。一种方法是修改单个测试函数,通过包装函数调用asyncio.run(...)
。这确保了测试系统是完全同步的,但是对于每一个单独的测试,都会建立一个事件循环,调度一个协同程序,并阻塞执行,直到它完成。
我们可以通过编写一个包含任何异步安装和拆卸的异步函数来实现这一点;然后,任何同步设置、拆卸和断言都被添加到主测试函数中。
def test_get_data_points_fails_with_bad_api_key(self, http_server):
async def wrapped():
async with aiohttp.ClientSession() as http:
return await collect.get_data_points(http_server, "incorrect", http)
with pytest.raises(
ValueError,
match=f"Error loading data from {http_server}: Supply API key in " f"X-API-Key header",
):
asyncio.run(wrapped())
前面的例子使用了一个http_server
fixture,它将 URL 返回给一个 API 服务器,然后创建一个协程来建立一个 aiohttp 会话并调用测试中的方法get_data_points(...)
。这里在清晰度方面有很大的牺牲:代码是无序的。首先列出异步代码,接着是断言,然后是同步代码。通常,我们根据程序的流程更自由地混合代码和断言。尽管我们可以将一些断言工作转移到测试的异步部分,但是总会有额外的代码为内部函数设置异步环境。
另一种方法是使用 pytest 插件来自动处理包装。这样做,使得混合标准测试方法和测试协程成为可能。任何使用 pytest 标记系统标记为 asyncio 测试的协程都是在异步环境中执行的,所有包装工作都在插件中透明地进行。
使用插件允许更清晰的执行流,不需要任何样板代码来弥合同步和异步代码之间的差距,如下所示:
@pytest.mark.asyncio
async def test_get_data_points_fails_with_bad_api_key(self, http_server):
with pytest.raises(
ValueError,
match=f"Error loading data from {http_server}: Supply API key " f"in X-API-Key header",
):
async with aiohttp.ClientSession() as http:
await collect.get_data_points(http_server, "incorrect", http)
Caution
我们在这里引入了一个依赖项,尽管它只在运行测试时适用。我们没有在setup.cfg
中列出测试依赖项,只是选择将它们作为开发依赖项包含在 Pipfile 中。因此,我们可以用
pipenv install --dev pytest-asyncio
这在大多数情况下是没问题的,但是在较大的代码库中,您可能需要测试组件和版本的组合,而不是只有一个 Pipfile。可以在setup.cfg
中列出测试依赖关系,以避免重复。为此,创建一个名为“test”的新的[options.extras_require]
行,并在那里列出测试依赖项。有一个遗留的 setuptools 特性叫做 tests_require,你可能有时会看到,但是我总是推荐一个额外的,因为它提供了对是否安装测试依赖的更明确的控制。
测试我们的代码
编写异步测试函数的能力是一个很好的开始,但是我们还需要设置一些装置来提供聚合代码传感器端点进行询问。对此有两种方法:我们可以提供模拟数据作为聚合测试的一部分,或者让聚合测试依赖于服务器代码并启动一个真实的(尽管是临时的)服务器。
这两种选择都不是特别有吸引力的前景;它们都有明显的缺点。如果我们编写测试来检查一个已知的 HTTP 响应,那么每次底层 API 改变时都需要更新这个响应。希望这不会经常发生,但是当人们阅读测试代码时,不透明的 JSON 块很难推理。
通常,操作大量数据的测试是通过复制输入数据,运行测试,然后使用输出数据编写一个assert
语句来编写的。这是一种有点危险的做法,因为它测试的是确保什么都没有改变,而不是检查某件事情是否正确。
另一种方法是运行后端服务器并连接到后端服务器,这是一种更现实的方法,可以避免在测试中使用原始 JSON,但是这增加了测试对服务器代码的依赖性。因此,所有的测试都需要创建一个套接字连接,并且增加了服务器安装和拆卸的开销。
这个困境和我们在第五章中面临的问题是一样的,我们必须在测试命令行界面的输出和直接测试传感器的功能之间做出选择。一旦我们认识到这一点,决定做什么就容易多了。功能测试为检查事情是否按预期运行提供了广泛的基础,但是更快、更专业的测试更容易开发。至关重要的是,两者都有助于我们区分底层平台发生变化时的测试失败和更快的测试对真实行为建模不佳时的测试失败。
因此,我将添加相同的标记来将这些测试声明为功能测试。在第五章中,我们在单个测试方法上用@pytest.mark.functional
做了这些,还有一个定义了功能标记的pytest.ini
文件。因为我们对这个包的所有功能测试都在一个不包含任何非功能测试的模块中,所以我们可以标记整个模块。通过设置pytestmark
模块变量来引用标记,类或模块可以有一个标记,如下所示:
import pytest
pytestmark = [pytest.mark.functional]
拆除测试服务器和 pytest 装置
对于我们的测试设置,我们需要做的第一件事是实例化一个测试服务器。服务器需要提供 HTTP 套接字,因为我们正在测试发出 HTTP 请求的代码。我们需要一个监听我们可以指定的端口的服务器,这样我们可以避免与其他软件的端口冲突;我们可能需要多台服务器同时运行,以测试数据是否可以从多个端点聚合。
在我们最初的apd.sensors
包中,我们创建了一个set_up_config(...)
函数,它接受配置值和一个可选的app
参数,然后将这些配置变量应用到应用中。如果没有提供app
,那么使用默认的应用(在已知的 URL 上设置各种 API 版本)。
为了创建具有不同配置的多个 flask 应用,我们需要能够创建功能上等同于默认应用的 flask 应用,这对于我们的测试来说意味着它们必须在/v/2.0
上提供 v2.0 API。我们可以通过复制来自apd.sensors
的一些代码来创建一个新的get_independent_flask_app(...)
函数,如清单 8-1 所示。
from concurrent.futures import ThreadPoolExecutor
import typing as t
import wsgiref.simple_server
import flask
import pytest
from apd.sensors.wsgi import v20
from apd.sensors.wsgi import set_up_config
def get_independent_flask_app(name: str) -> flask.Flask:
""" Create a new flask app with the v20 API blueprint loaded, so multiple copies
of the app can be run in parallel without conflicting configuration """
app = flask.Flask(name)
app.register_blueprint(v20.version, url_prefix="/v/2.0")
return app
def run_server_in_thread(name: str, config: t.Dict[str, t.Any], port: int) -> t.Iterator[str]:
# Create a new flask app and load in required code, to prevent config # conflicts
app = get_independent_flask_app(name)
flask_app = set_up_config(config, app)
server = wsgiref.simple_server.make_server("localhost", port, flask_app)
with ThreadPoolExecutor() as pool:
pool.submit(server.serve_forever)
yield f"http://localhost:{port}/"
server.shutdown()
@pytest.fixture(scope="session")
def http_server() -> t.Iterator[str]:
yield from run_server_in_thread(
"standard", {"APD_SENSORS_API_KEY": "testing"}, 12081
)
Listing 8-1Helper functions and a fixture to run a HTTP server
这个函数允许我们创建具有独立配置的 flask 应用,但所有应用都在正确的 URL 上包含 v2.0 API。run_server_in_thread(...)
实用函数是一个更高级的函数,用于创建 flask 应用,对其进行配置,并使其服务于请求。
Note
对于是否值得向测试方法中添加类型定义,还存在一些争议。我发现 PyTest 对类型支持的缺乏移除了大部分的实用程序,但是它在很大程度上依赖于您的代码库。如果你对类型有很好的了解,你会发现这是值得的。我个人推荐类型检查实用函数,在测试方法和 fixtures 中添加返回类型注释。这通常足以确保您的测试助手在使用时进行类型检查,但是我建议在测试方法的类型方面更加务实,我经常跳过这一点。
为了服务请求,我们将使用标准库中的 wsgiref 服务器。我们之前使用它的serve_forever()
函数来处理请求,作为测试apd.sensors
HTTP 服务器的一部分。这几乎正是我们想要的,因为它采用了一个 WSGI 应用,并通过 HTTP 使它可用;但是它是以阻塞的方式实现的。一旦我们调用serve_forever()
,服务器正常运行,直到用户用<CTRL+c>
中断它。这不是我们想要的测试设备,所以我们需要卸载它来并发运行。
线程化的执行模型非常适合这一点:我们可以产生一个新的线程来处理serve_forever()
调用,并在我们处理完服务器后中断它。与我们以前编写的 fixtures 不同,我们不只是想创建一个值并将其传递给测试方法,我们还想进行设置、传递一个值,然后进行拆卸以清理我们已经创建的线程。
进行设置和拆卸的 Pytest fixtures 使用关键字yield
而不是return
,有效地使 fixture 成为一个单项生成器。在yield
关键字之前的任何东西都被正常执行,产生的值是作为参数提供给测试函数的。yield
之后的任何操作仅在夹具拆除后执行。默认情况下,夹具在每次测试结束时都会被拆除。我们可以将范围更改为“session
”,这意味着每次 pytest 调用时,fixture 应该只设置和拆除一次,而不是在每次测试之后。
这种结构允许在最后一个需要http_server
的测试完成后进行server.shutdown()
调用和线程池清理。
Note
shutdown 方法是标准库中 WSGIServer 的一个实现细节,但它是一个关键的细节。一旦我们的测试方法执行完毕,我们想要关闭服务请求的线程。如果我们不这样做,那么测试程序将挂起,等待线程完成,但是线程在正常操作中永远不会终止。shutdown 方法操作一个内部标志,wsgiref 服务器每 500 毫秒检查一次该标志。如果它被设置,serve_forever()
调用返回,因此导致线程退出。
线程中运行的任何东西都必须在进程完成之前被明确关闭。在这种情况下,我们很幸运这个 API 在设计时就考虑到了这一点,但是如果你使用的是其他不提供关闭功能的 API,你可能需要创建自己的共享变量,并在提交给池的函数中检查它。不可能从外部强制线程停止;你的线程必须在不再需要的时候停止。
utility 函数允许我们创建多个这样的测试服务器,仅在配置上有所不同,并将它们的地址传递给测试方法。我们可以创建尽可能多的装置,向每个装置传递不同的数据。例如,下面给出了一个设置服务器的 fixture,该服务器使用不同的 API 键,因此会拒绝请求:
@pytest.fixture(scope="session")
def bad_api_key_http_server():
yield from run_server_in_thread(
"alternate", {"APD_SENSORS_API_KEY": "penny"}, 12082
)
这里最后要提到的是夹具本身的yield from
结构。一个yield
from
表达式在构建发电机时非常有用。当给定一个 iterable 时,它放弃值,然后将执行传递给下一行。这允许编写遵从另一个迭代器的迭代器,作为更复杂的实现的一部分,例如,在现有迭代器的开头和结尾附加附加项的迭代器。它还可以用来将多个迭代器链接在一起,尽管标准库中的itertools.chain
函数可能更适合这个目的。22
def additional(base_iterator):
yield "Start"
yield from base_iterator
yield "End"
Pytest 对待 fixture 的值与对待 fixture 的值是不同的,所以尽管我们不想操作我们正在包装的迭代器,但是我们需要对它进行迭代并产生单个值,以便 pytest 知道这个 fixture 有设置和拆卸。Pytest 通过内省 fixture 函数并检查它是否是一个生成器函数来确定这一点。 3 如果包装函数体是return run_server_in_thread(...)
,那么,尽管调用函数的实际结果是一样的,但函数本身不会被认为是生成器函数。这是一个返回生成器的函数。
自省函数允许 fixtures 有意返回生成器,比如下面的例子返回一个只有一个值的生成器。如果这个 fixture 被用在一个测试函数中,那么这个函数将被赋予生成器本身,而不是它的单个值。
@pytest.fixture
def single_item_iterator():
def gen_func():
yield "An item"
return gen_func()
Fixture scoping
默认情况下,所有的 fixture 都在测试级别,这意味着 fixture 代码对于依赖它们的每个测试都运行一次。我们创建一个新的 HTTP 服务器的 fixtures 的作用域是在会话级别,这意味着它们只运行一次,并且所有测试共享这个值。
夹具可以使用其他夹具,作为在多个夹具和测试之间共享设置代码的一种方式。例如,在未来,作为apd.sensors
的服务器设置的一部分,我们可能需要更多的配置值。在这种情况下,我们不想为每个正在设置的 HTTP 服务器都重复它们;我们希望将默认配置放在一个夹具中,如清单 8-2 所示。这样,HTTP 服务器设备和任何需要配置值的测试都可以读取它。
import copy
@pytest.fixture(scope="session")
def config_defaults():
return {
"APD_SENSORS_API_KEY": "testing",
"APD_SOME_VALUE": "example",
"APD_OTHER_THING": "off"
}
@pytest.fixture(scope="session")
def http_server(config_defaults) -> t.Iterator[str]:
config = copy.copy(config_defaults)
yield from run_server_in_thread("standard", config, 12081)
@pytest.fixture(scope="session")
def bad_api_key_http_server(config_defaults) -> t.Iterator[str]:
config = copy.copy(config_defaults)
config["APD_SENSORS_API_KEY"] = "penny"
yield from run_server_in_thread(
"alternate", config, 12082
)
Listing 8-2Changes to the fixtures to support a common config fixture
这个假设的config_defaults
fixture 已经设置了scope="session"
,因为它也在会话范围级别运行。然而,这是由会话范围的 fixtures 使用的逻辑结果,而不是自由选择。如果config_defaults
夹具的范围更窄,那么就会出现矛盾。应该根据狭窄的范围设置和拆除它,还是在拆除依赖于它的会话范围的项目之后设置和拆除它?
我们的例子可能看起来无害,但是如果 fixture 返回动态值,或者设置一些资源,那么行为需要一致。因此,任何试图使用范围比正在使用它的 fixture 更窄的 fixture 的操作都会导致 pytest 失败,并出现范围不匹配错误,如下所示:
ScopeMismatch: You tried to access the 'function' scoped fixture 'config_defaults' with a 'session' scoped request object, involved factories
tests\test_http_get.py:57: def http_server(config_defaults)
tests\test_http_get.py:49: def config_defaults()
开发人员可以使用几个作用域;这些是(从最窄到最宽)function
、class
、module
、package
、、 4 、、session
。缺省值是 function,任何定义了显式作用域的 fixture 必须只依赖于使用该作用域或更宽作用域的 fixture。例如,任何类范围的 fixture 都可以依赖于类、模块、包或会话 fixture,但不能依赖于函数范围的 fixture。
有点令人困惑的是,还有第二种类型的范围适用于 fixtures,它们的可发现性。这由代码库中定义 fixture 的位置来定义。它决定了哪些函数可以使用 fixture,但是对如何在测试之间共享 fixture 调用没有影响。
我们之前创建的 HTTP 服务器设备被指定为在会话范围内,但是它们被定义在一个测试模块中,这使得它们的可发现性等同于模块范围。有三种可能的可发现性范围,相当于类、模块和包。在conftest.py
模块中定义的夹具可用于代码库中的所有测试;在一个测试模块中定义的可用于该模块中的所有测试;而那些被定义为测试类的方法的测试对该类中的所有测试都是可用的。
发现范围与定义范围不同是很常见的,特别是当 fixture 的默认范围是 function 时,它没有等价的可发现性范围。如果可发现性比声明的范围更广,那么在整个测试过程中,可以多次设置、使用和拆卸夹具。如果是相同的,那么夹具将被设置、使用,然后立即拆除。最后,如果一个测试声明的范围比它的可发现性更广,那么它将不会被拆除,直到测试运行中的某个稍后的点,可能是在不再需要它之后很久。表 8-1 展示了这三种可能性。
表 8-1
15 种不同范围组合的效果
| |scope=function
|
scope=class
|
scope=module
|
scope=package
|
scope=session
|
| — | — | — | — | — | — |
| 定义在一个类中 | 多次调用 | 一次祈祷 | 延迟拆卸 | 延迟拆卸 | 延迟拆卸 |
| 在模块中定义 | 多次调用 | 多次调用 | 一次祈祷 | 延迟拆卸 | 延迟拆卸 |
| 在 conftest.py 中定义 | 多次调用 | 多次调用 | 多次调用 | 一次祈祷 | 延迟拆卸 |
如果存在多个同名的装置,那么每个测试使用发现范围最窄的一个。也就是说,在conftest.py
中定义的 fixture 可用于所有的测试,但是如果一个模块有一个同名的 fixture,那么这个 fixture 将用于模块内的测试。如果一个类有一个同名的 fixture,情况也是如此。
Caution
这种超越仅仅是关于发现;对夹具的寿命及其拆卸行为没有影响。如果您有一个设置和拆除资源的 fixture,比如我们的 HTTP 服务器,并且您为一个类覆盖了它,那么同一 fixture 的其他版本可能已经设置好了,但是还没有拆除。 5 任何时候你定义一个 fixture,其中使用的最窄覆盖和使用的最宽声明范围在表 8-1 中被列为“延迟拆卸”,你必须确保你的 fixture 不试图持有相同的资源,例如 TCP/IP 套接字。
我们的代码中确实有不匹配的地方:我们的 HTTP 服务器 fixture 是在一个测试模块中定义的,但是使用了会话范围,所以它可能会遭受延迟拆卸。我们可以通过将 fixtures 移动到conftest.py
或者将声明的范围更改为module
来解决这个问题。我们需要决定我们是否希望我们的 fixture 与测试运行保持一致,并且可供任何测试使用,或者我们是否希望它只供test_http_get.py
测试模块使用,并且一旦这些测试被执行,它就被拆除。
由于我们不打算创建一个需要使用这个 fixture 的功能测试的扩展测试套件,我将把它留在测试模块中,并缩小匹配的范围。
模仿对象以简化单元测试
为了编写代码的单元测试,我们需要找到一种替代方法来启动 aiohttp 库要连接的服务器。如果我们使用 requests 库发出 HTTP 请求,我们可能会使用 responses 测试工具,该工具会修补请求内部的某些部分,以允许覆盖特定的 URL。
如果我们的get_data_points(...)
实现是同步的,我们将注册我们想要用响应覆盖的 URL,并确保为测试方法激活了包。使用响应的测试函数,比如如下所示的假设函数,不会以牺牲可读性为代价引入过多的复杂性。
@responses.activate
def test_get_data_points(self, mut, data) -> None:
responses.add(responses.GET, 'http://localhost/v/2.0/sensors/',
json=data, status=200)
datapoints = mut("http://localhost", "")
assert len(datapoints) == len(data["sensors"])
for sensor in data["sensors"]:
assert sensor["value] in (datapoint.data for datapoint in datapoints)
assert sensor["id"] in (datapoint.sensor_name for datapoint in datapoints)
我们希望能够为 aiohttp 库做一些类似的事情,但是我们有一点优势,因为我们的函数期望将一个 http 客户端对象传递给get_data_points(...)
函数。我们可以编写一个模拟版本的ClientSession
对象,它的行为与真实对象非常相似,允许我们注入假数据,而不必像 responses 那样修补真实的实现。
对于简单的对象,我们经常使用标准库中内置的unittest.mock
功能。模仿允许我们实例化对象并定义各种操作的结果。我们需要的对象有一个get(...)
方法,它返回一个上下文管理器。这个上下文管理器的 enter 方法返回一个响应对象,它有一个status
属性和一个json()
协程,这是一组相对复杂的需求。清单 8-3 展示了一个使用unittest.mock
构建这个对象的夹具。
from unittest.mock import Mock, MagicMock, AsyncMock
import pytest
@pytest.fixture
def data() -> t.Any:
return {
"sensors": [
{
"human_readable": "3.7",
"id": "PythonVersion",
"title": "Python Version",
"value": [3, 7, 2, "final", 0],
},
{
"human_readable": "Not connected",
"id": "ACStatus",
"title": "AC Connected",
"value": False,
},
]
}
@pytest.fixture
def mockclient(data):
client = MagicMock()
response = Mock()
response.json = AsyncMock(return_value=data)
response.status = 200
client.get.return_value.__aenter__ = AsyncMock(return_value=response)
return client
Listing 8-3Using unittest’s mocking to mock a complex object
这个对象不太容易推理:mockclient
中的代码相当密集,它依赖于理解不同类型的可用模拟类之间的差异,以及上下文管理器的实现。您不能一眼看出如何从测试夹具中使用这个对象。
我们可以通过创建定制类来编写相同的功能,这些定制类反映了我们想要替换的真实类的功能,如清单 8-4 所示。这种方法导致代码非常长,所以一些开发人员更喜欢前面提到的通用模仿方法。
import contextlib
from dataclasses import dataclass
import typing as t
import pytest
@pytest.fixture
def data() -> t.Any:
return {
"sensors": [
{
"human_readable": "3.7",
"id": "PythonVersion",
"title": "Python Version",
"value": [3, 7, 2, "final", 0],
},
{
"human_readable": "Not connected",
"id": "ACStatus",
"title": "AC Connected",
"value": False,
},
]
}
@dataclass
class FakeAIOHttpClient:
data: t.Any
@contextlib.asynccontextmanager
async def get(self, url: str, headers: t.Optional[t.Dict[str, str]]=None) -> FakeAIOHttpResponse:
yield FakeAIOHttpResponse(json_data=self.data, status=200)
@dataclass
class FakeAIOHttpResponse:
json_data: t.Any
status: int
async def json(self) -> t.Any:
return self.json_data
@pytest.fixture
def mockclient(data) -> FakeAIOHttpClient:
return FakeAIOHttpClient(data)
Listing 8-4Manually mocking a complex object
使用这种方法的设置时间大约是两倍,但是一眼就能看出所涉及的对象是什么要容易得多。这两种方法之间的差异很大程度上是个人偏好的差异。就我个人而言,在大多数情况下我更喜欢第二种方法,因为我觉得它有一些具体的优点。
unittest.mock
方法为所有属性访问创建模拟。这可能会引入微妙的测试错误,因为代码可能会开始依赖于一个新的属性,而这在默认情况下会被模拟出来。例如,如果我们编写了一些使用了if response.cookies:
的代码,那么第一种模拟方法将总是在模拟会话中对True
求值,但是第二种方法将引发AttributeError
。我通常更愿意知道我的模仿是不完整的,通过异常,而不是不正确的行为。
然后,当编写包含分支逻辑的模拟时,前一种方法更难使用。它们非常适合断言遵循了什么代码路径,但是不太适合根据情况返回不同的数据。例如,如果我们想要一个模拟会话,它可以为不同的 URL 返回不同的数据,那么对定制对象的更改就相对清楚了。使用模拟对象时的等效变化要复杂得多。
带有分支逻辑的模拟
要使用Fake*
对象引入每个 url 的模拟响应,只需要修改FakeAIOHttpClient
类及其在mockclient
中的调用,这些修改是非常标准的 Python 逻辑。
@dataclass
class FakeAIOHttpClient:
responses: t.Dict[str, str]
@contextlib.asynccontextmanager
async def get(self, url: str, headers: t.Optional[t.Dict[str, str]]=None) -> FakeAIOHttpResponse:
if url in self.responses:
yield FakeAIOHttpResponse(json_data=self.responses[url], status=200)
else:
yield FakeAIOHttpResponse(json_data=None, status=404)
然而,对基于 unittest 的模拟系统的等效更改需要更多的支持代码,并且需要对一些工作进行重构,以更类似于我们的定制模拟方法。
def FakeAIOHTTPClient(response_data):
client = Mock()
def find_response(url):
get_request = MagicMock()
response = Mock()
if url in response_data:
response.json = AsyncMock(return_value=response_data[url])()
response.status = 200
else:
response.json = AsyncMock(return_value=None)()
response.status = 404
get_request.__aenter__ = AsyncMock(return_value=response)
return get_request
client.get = find_response
return client
@pytest.fixture
def mockclient(data):
return FakeAIOHTTPClient({
"http://localhost/v/2.0/sensors/": data
})
数据类别
你可能已经注意到了前面几个类中的@dataclass
装饰,因为我们还没有用到它们。数据类是 3.7 版本中引入的 Python 特性。它们大致相当于旧版本 Python 中广泛使用的命名元组特性;它们是定义数据容器的一种方式,可以最大限度地减少所需的样板文件。
通常,当定义一个类来存储数据时,我们必须定义一个__init__(...)
方法来获取参数(可能带有默认值),然后将这些参数设置为实例属性。每个字段名出现三次,一次在参数列表中,一次在赋值操作的两边,例如,我们的假响应对象的以下变体,它只存储两条数据:
class FakeAIOHttpResponse:
def __init__(self, body: str, status: int):
self.body = body
self.status = status
许多 Python 开发人员都非常熟悉这种类结构,因为我们经常需要创建存储结构化数据的方法,这些方法使用属性访问来检索字段。collections.namedtuple(...)
函数是一种以声明方式实现这一点的方法:
import collections
FakeAIOHttpResponse = collections.namedtuple("FakeAIOHttpResponse", ["body", "status"])
除了减少声明只包含样板代码的类的需要之外,这样做还有一个好处,即确保返回对象的有用文本表示,以及像==
和!=
这样的比较操作符的行为符合预期。我们前面提到的原始类不比较类上的值,所以FakeAIOHttpResponse("", 200) == FakeAIOHttpResponse("", 200)
用类版本评估为 False,用命名的元组版本评估为 True。
命名元组是一种特殊类型的元组;可以使用带有字段名称的属性访问或带有索引的项目访问来访问项目。即对于一个FakeAIOHttpResponse
、x.body == x[0]
的实例。最后,它们提供了一个_asdict()
实用方法,该方法返回一个字典,其中包含与命名元组实例相同的数据。
命名元组的最大缺点是它们不容易添加方法。可以对命名元组进行子类化,并以这种方式添加方法,但我不建议这样做,因为可读性较差。
class FakeAIOHttpResponse(collections.namedtuple("", ["body", "status"])):
async def json(self) -> t.Any:
return json.loads(self.body)
这就是数据类的闪光点。通过在类定义上使用@dataclasses.dataclass
decorator,可以将一个类变成一个数据类。使用类型语法定义字段,可以选择使用默认值。dataclass decorator 负责将这些类变量转换成定制的__init__(...)
、__repr__()
、__eq__(...)
和其他方法。
@dataclass
class FakeAIOHttpResponse:
body: str
status: int = 200
async def json(self) -> t.Any:
return json.loads(self.body)
Tip
有时候,除了存储值之外,您还想在__init__
方法中添加其他代码。您可以通过定义一个__post_init__
方法来对数据类执行此操作,该方法将在__init__
中的样板文件完成后被调用。
尽管数据类提供了许多与命名元组相同的特性,但它们并不完全与命名元组提供的 API 兼容。它们不实现条目访问、 6 ,并且到字典和元组的转换是通过dataclasses.asdict(...)
和dataclasses.astuple(...)
函数完成的,而不是通过类本身的方法。
数据类相对于命名元组的另一个优势是它们是可变的,尽管我们在这里没有用到。在数据类对象被实例化之后,可以改变它的属性值。命名元组就不一样了。此功能是可选的;用@dataclass(frozen=True)
定义的类不支持在实例化后改变属性。冻结一个数据类的好处是它也可以被哈希(??),这意味着它可以作为集合的一部分或者字典的键来存储。
Caution
尽管被冻结的数据类不允许它们的值被替换为,但是如果其中一个值是可变的,那么这个字段有可能被就地改变。如果你使用列表、集合或字典作为值类型,我不推荐使用frozen=True
选项。
还有一些其他的选项可以传递给@dataclass
装饰器:eq=False
抑制等式函数的生成,这样相同值的实例就不会相等。或者,传递order=True
会额外生成丰富的比较字段,其中对象的排序与它们的值的元组一样,按顺序排列。
对于一些高级用例,可以指定每个字段的元数据。例如,我们可能希望响应的 repr 看起来像FakeAIOHttpResponse(url='http://localhost', status=200)
,也就是说,添加一个 URL 项并从 repr 中省略主体。我们可以通过使用一个field
对象来做到这一点,这与编写自定义__repr__()
方法的标准方法相反。两种方法的比较如表 8-2 所示。
表 8-2
使用和不使用 dataclass 助手的自定义 repr 行为的比较
| *使用字段(...)自定义默认 repr*`from dataclasses import dataclass, field``@dataclass``class FakeAIOHttpResponse:``url: str``body: str = field(repr=False)``status: int = 200``async def json(self) -> t.Any:``return json.loads(self.body)` | *使用自定义 __repr__*`from dataclasses import dataclass``@dataclass``class FakeAIOHttpResponse:``url: str``body: str``status: int = 200``def __repr__(self):``name = type(self).__name__``url = self.url``status = self.status``return f"{name}({url=}, {status=})"``async def json(self) -> t.Any:``return json.loads(self.body)` |field(...)
方法的优点是明显更短,尽管稍微不太直观。__repr__()
方法允许完全控制,代价是需要重新实现默认行为。
在某些情况下,field 方法是强制的:支持默认为可变对象的字段,比如 list 或 dict。这与建议不要使用可变对象作为函数的默认值是出于同样的原因,因为它们被就地修改会导致数据在实例间溢出。
字段对象接受一个default_factory
参数,这是一个可调用的参数,为每个实例生成默认值。这可以是用户指定的函数,也可以是不带参数的类构造函数。
options: t.List[str] = field(default_factory=list)
上下文库
与我们使用yield
分割 pytest fixture 的安装和拆卸部分一样,我们可以使用标准库中的contextlib
的装饰器来创建上下文管理器,而不必显式实现__enter__()
和__exit__(...)
方法对。
装饰器是创建上下文管理器最简单的方法,尤其是我们在这里使用的非常简单的方法。上下文管理器最常见的用途是创建一些资源,并确保它在之后被正确清理。表 8-3 显示,如果我们正在制作一个上下文管理器,其行为方式与之前的 HTTP 服务器 fixture 相同,那么代码几乎是相同的。
表 8-3
具有拆卸功能的 pytest fixture 与上下文管理器的比较
| *Pytest fixture 创建 HTTP 服务器*`import pytest``@pytest.fixture(scope="module")``def http_server():``yield from run_server_in_thread(``"standard", {``"APD_SENSORS_API_KEY": "testing"``}, 12081``)` | *上下文管理器创建一个 HTTP 服务器*`import contextlib``@contextlib.contextmanager``def http_server():``yield from run_server_in_thread(``"standard", {``"APD_SENSORS_API_KEY": "testing"``}, 12081``)` |更复杂的上下文管理器,比如需要处理发生在它们包装的代码中的异常的上下文管理器,需要将yield
语句视为可能引发异常的语句。因此,yield
语句通常应该在try
/ finally
块或with
块中,以确保任何资源都被正确地拆除。
FakeAIOHttpClient
上的get(...)
方法是异步上下文管理器,而不是标准上下文管理器。@contextlib.contextmanager
装饰器从生成器方法中创建__enter__()
和__exit__(...)
方法;我们需要的是一个装饰器来从一个生成器协程创建__aenter__()
和__aexit__(...)
协程。这可以作为@contextlib.asynccontextmanager
装饰器获得。
测试方法
既然我们已经准备好支持代码的快速集成测试,我们就可以开始编写实际的测试函数了。首先,我们可以在没有 HTTP 服务器开销的情况下验证get_data_points(...)
方法的行为。 7 然后我们可以根据get_data_points(...)
为add_data_from_sensors(...)
方法添加测试。最后,我们需要测试来确保应用的数据库部分正常工作,我们仍然需要修改它来消除阻塞行为。
清单 8-5 中显示的测试方法结合了我们目前使用的技术。对get_data_points(...)
的测试使用定制对象生成的mockclient
。这是所有依赖于 HTTP 库的准确行为的一组测试中的第一个。另一方面,add_data_from_sensors
测试使用一个unittest.mock.Mock()
对象来模拟数据库会话,因为我们只需要断言某些方法在我们期望的时候被调用。
patch_aiohttp()
夹具结合了这两种方法,以及夹具的安装和拆卸功能。只要上下文管理器是活动的,unittest.mock.patch(...)
上下文管理器就获取一个 Python 对象的位置并用一个 mock 替换它。由于add_data_from_sensors(...)
方法不接受ClientSession
作为参数,所以我们不能将自定义的模拟传递给它。这允许我们将我们的定制模拟方法移植到 aiohttp 库,每当我们的测试代码创建一个ClientSession
时就返回,就像 responses 对 requests 库所做的那样。
from unittest.mock import patch, Mock, AsyncMock
import pytest
import apd.aggregation.collect
class TestGetDataPoints:
@pytest.fixture
def mut(self):
return apd.aggregation.collect.get_data_points
@pytest.mark.asyncio
async def test_get_data_points(
self, mut, mockclient: FakeAIOHttpClient, data
) -> None:
datapoints = await mut("http://localhost", "", mockclient)
assert len(datapoints) == len(data["sensors"])
for sensor in data["sensors"]:
assert sensor["value"] in (datapoint.data for datapoint in datapoints)
assert sensor["id"] in (datapoint.sensor_name for datapoint in datapoints)
class TestAddDataFromSensors:
@pytest.fixture
def mut(self):
return apd.aggregation.collect.add_data_from_sensors
@pytest.fixture(autouse=True)
def patch_aiohttp(self, mockclient):
# Ensure all tests in this class use the mockclient
with patch("aiohttp.ClientSession") as ClientSession:
ClientSession.return_value.__aenter__ = AsyncMock(return_value=mockclient)
yield ClientSession
@pytest.fixture
def db_session(self):
return Mock()
@pytest.mark.asyncio
async def test_datapoints_are_added_to_the_session(self, mut, db_session) -> None:
# The only times data should be added to the session are when # running the MUT
assert db_session.add.call_count == 0
datapoints = await mut(db_session, ["http://localhost"], "")
assert db_session.add.call_count == len(datapoints)
Listing 8-5The various approaches of test methods for apd.aggregation
最终的测试并不过分复杂,并且覆盖了与功能测试相同的一般功能。它们为未来的测试提供了一个基础,功能测试提供了一个退路,让我们确信我们的测试有有用的断言。这里的集成测试都是阳性,确认正常情况下有效。我们还没有任何证据证明不寻常的或边缘的情况得到了正确的处理,但它们是一个很好的起点。
异步数据库
到目前为止,我们一直使用 SQLAlchemy ORM 来处理数据库和 Python 代码之间的所有交互,因为它允许将数据库的许多特性放到一边,以支持看起来正常的 Python 代码。不幸的是,SQLAlchemy ORM 不适合在纯异步环境中使用。SQLAlchemy 不保证 SQL 查询只在响应session.query(...)
调用时运行;访问对象的属性时也可以运行查询,更不用说插入和事务管理查询了。所有这些调用都会阻塞执行,严重影响 asyncio 应用的性能。
这并不意味着 SQLAlchemy ORM 在异步上下文中运行时会更慢;阻塞通常是最小的,并且仍然存在于 SQLAlchemy 的同步使用中。相反,这意味着在异步代码中使用 SQLAlchemy ORM 会导致性能下降到与同步代码相同的水平,从而抵消了使用 asyncio 的许多好处。
如果我们愿意牺牲 SQLAlchemy 的 ORM 组件,只将其用作 SQL 语句生成器和接口,就不会出现无意查询的风险。这是一个真正的损失,是我们到目前为止考虑的与使我们的代码异步相关的最大损失,因为 SQLAlchemy ORM 是一个设计如此良好的库。
在撰写本文时,数据库连接还没有完美的解决方案;但是,我觉得语句生成方法是一个很好的折衷方案。只要您没有编写异步服务器应用,并且能够承受性能下降的风险,您就应该考虑使用 ORM 的实用方法,尽一切努力避免在主线程中调用阻塞代码。
经典的 SQLAlchemy 风格
在我们的例子中,我们将使用语句生成方法。我们不能继续使用之前创建的基于declarative_base
的类,因为这可能会无意中触发 SQL 查询。使用“经典”样式(即,不是直接从它们所代表的 Python 类派生的显式表对象)并且不配置 ORM 来链接表和我们的 Python 对象,这让我们可以安全地使用DataPoint
对象,而不会触发隐式查询。清单 8-6 中给出了我们现有表格的实现。
这种方法意味着我们将不会直接在数据库层处理我们的自定义对象,我们将处理表,并将负责我们的对象和 SQLAlchemy API 之间的转换。然而,我们只是改变了我们表示数据库的方式,而不是数据库结构,所以我们不需要为这种改变创建任何迁移。
from dataclasses import dataclass, field
import datetime
import typing as t
import sqlalchemy
from sqlalchemy.dialects.postgresql import JSONB, TIMESTAMP
from sqlalchemy.schema import Table
metadata = sqlalchemy.MetaData()
datapoint_table = Table(
"sensor_values",
metadata,
sqlalchemy.Column("id", sqlalchemy.Integer, primary_key=True),
sqlalchemy.Column("sensor_name", sqlalchemy.String),
sqlalchemy.Column("collected_at", TIMESTAMP),
sqlalchemy.Column("data", JSONB),
)
@dataclass
class DataPoint:
sensor_name: str
data: t.Dict[str, t.Any]
id: int = None
collected_at: datetime.datetime = field(default_factory=datetime.datetime.now)
Listing 8-6The “classic” style, with independent table and data classes
在我们做任何事情之前,我们应该更新我们的alembic/env.py
脚本,因为它需要引用metadata
对象来生成迁移。之前是导入Base
,然后接入Base.metadata
;我们必须修改这些行来使用我们的新元数据对象,apd.aggregation.database.metadata
。
我们不能再通过实例化一个DataPoint
对象并将其添加到会话中来创建数据库记录;相反,我们直接对datapoint_table
结构进行插入调用。
stmt = datapoint_table.insert().values(
sensor_name="ACStatus",
collected_at=datetime.datetime(2020,4,1,12,00,00),
data=False
)
session.execute(stmt)
stmt
对象是 SQLAlchemy 中Insert
的一个实例。此对象代表要执行的 SQL 语句的结构;它不是直接传递给数据库的字符串。虽然可以查看表示语句的字符串,但是我们需要指定它用于哪种数据库,以便获得准确的结果。这是 SQLAlchemy 通过基于连接信息的stmt.compile(dialect=...)
方法调用在内部完成的。不同数据库的 SQL 标准和指定插值的方式略有不同;编译步骤是应用特定于数据库的语法。作为防止 SQL 注入漏洞工作的一部分,所有的变体都将从 SQL 结构中传递的值分开。
未编译
INSERT INTO datapoints (sensor_name, collected_at, data) VALUES (:sensor_name, :collected_at, :data)
{'sensor_name': 'ACStatus', 'collected_at': datetime.datetime(2020, 4, 1, 12, 0), 'data': False}
数据库
INSERT INTO datapoints (sensor_name, collected_at, data) VALUES (:sensor_name, :collected_at, :data)
{'sensor_name': 'ACStatus', 'collected_at': datetime.datetime(2020, 4, 1, 12, 0), 'data': False}
关系型数据库
INSERT INTO datapoints (sensor_name, collected_at, data) VALUES (%s, %s, %s)
['ACStatus', datetime.datetime(2020, 4, 1, 12, 0), False]
一种数据库系统
INSERT INTO datapoints (id, sensor_name, collected_at, data) VALUES (%(id)s, %(sensor_name)s, %(collected_at)s, %(data)s)
{'id': None, 'sensor_name': 'ACStatus', 'collected_at': datetime.datetime(2020, 4, 1, 12, 0), 'data': False}
数据库
INSERT INTO datapoints (sensor_name, collected_at, data) VALUES (?, ?, ?)
['ACStatus', datetime.datetime(2020, 4, 1, 12, 0), False]
除了好奇,我们不需要看这些字符串,也不需要手动编译 insert 语句。我们通过 SQLAlchemy 建立的会话在使用session.execute(stmt)
执行时直接处理一个Insert
对象。
这个execute(...)
方法将语句发送到数据库并等待响应。例如,如果有一个 SQL 锁需要等待,这个 Python 语句就可以阻塞。session.commit()
调用也可能导致阻塞,因为这是前面的插入命令被终结的地方。简而言之,使用这种方法,我们需要确保任何涉及会话的调用总是发生在不同的线程中。
忽略 SQL 生成的细节而只调用table.insert().values(...)
的能力展示了我们通过使用 SQLAlchemy 保留的一些优势,即使是以这种更有限的方式。通过编写在两种数据类型之间转换的实用函数,我们可以做得更好。我们最初可能会尝试使用**dataclasses.asdict(...)
来生成values(...)
调用的主体,但这将包括id=None
。我们不想在 SQL insert 中将 id 设置为None
,我们想从参数列表中省略它,以便数据库设置它。为了使这更容易,我们将在数据类(清单 8-7 )上创建一个调用asdict(self)
的函数,但该函数只包含显式设置的 id。
from dataclasses import dataclass, field, asdict
import datetime
import typing as t
@dataclass
class DataPoint:
sensor_name: str
data: t.Dict[str, t.Any]
id: int = None
collected_at: datetime.datetime = field(default_factory=datetime.datetime.now)
def _asdict(self):
data = asdict(self)
if data["id"] is None:
del data["id"]
return data
Listing 8-7Implementation of DataPoint class with a helper method for database queries
使用运行执行程序
我们在前一章简单讨论了run_in_executor(...)
函数,以允许time.sleep(1)
与asyncio.sleep(1)
并行运行而不是顺序运行为例。这是一个相当不自然的例子,但是将数据库调用转移到一个新的线程中非常适合。
Caution
run_in_executor(...)
方法与我们之前使用的with ThreadPoolExecutor()
结构不可互换。两者都将工作委托给一个线程;池执行器构造建立一个池,提交工作,然后等待所有工作完成,而run_in_executor(...)
方法创建一个长时间运行的池,允许您提交任务并等待来自异步代码的值。
到目前为止,我们使用的许多 asyncio 帮助函数,如asyncio.gather(...)
、asyncio.create_task(...)
和asyncio.Lock()
,都会自动检测当前的 asyncio 事件循环。run_in_executor(...)
功能有点不一样;它只能作为事件循环实例上的方法使用。我们需要用asyncio.get_running_loop()
自己获取当前事件循环,然后用它来提交要在执行器中运行的函数。我建议提交一个同步任务来完成您需要的所有工作,而不是为每个低级调用提交单独的任务并用 asyncio 逻辑将它们粘合在一起,例如,创建一个为一组对象生成插入查询的handle_result(...)
函数(清单 8-8 ),而不是为每个要插入的对象创建一个函数调用。
def handle_result(result: t.List[DataPoint], session: Session) -> t.List[DataPoint]:
for point in result:
insert = datapoint_table.insert().values(**point._asdict())
sql_result = session.execute(insert)
point.id = sql_result.inserted_primary_key[0]
return result
async def add_data_from_sensors(
session: Session, servers: t.Tuple[str], api_key: t.Optional[str]
) -> t.List[DataPoint]:
tasks: t.List[t.Awaitable[t.List[DataPoint]]] = []
points: t.List[DataPoint] = []
async with aiohttp.ClientSession() as http:
tasks = [get_data_points(server, api_key, http) for server in servers]
for results in await asyncio.gather(*tasks):
points += results
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, handle_result, points, session)
return points
Listing 8-8Database integration function for adding data points
loop.run_in_executor
的参数是(executor, callable, *args)
,其中executor
必须是ThreadPoolExecutor
或None
的一个实例(使用默认的执行程序,必要时创建它)。
Tip
如果您正在适应大量的同步任务,我建议您直接管理线程池。这将允许您设置他们的工人数量,从而设置他们将执行的同时任务的数量。这还将允许您在决定需要添加什么锁来使代码线程安全时,更有效地推理哪些代码可以同时执行。
在这个执行器中,callable
函数将作为一个任务被调用,其位置参数在*args
中指定。您不能将关键字参数作为此 API 的一部分指定给 callable。
使用需要关键字参数的函数的最佳方式是使用functools.partial(...)
函数。它将一个函数转换成另一个参数更少的函数。如果我们将handle_result(...)
函数包装在一个分部函数中,如下所示,那么下面的函数调用将是等效的:
>>> only_points = functools.partial(handle_result, session=Session)
>>> only_session = functools.partial(handle_result, points=points)
>>> no_args = functools.partial(handle_result, points=points, session=Session)
>>> handle_result(points=points, session=Session)
[DataPoint(...), DataPoint(...)]
>>> only_points(points=points)
[DataPoint(...), DataPoint(...)]
>>> only_session(session=Session)
[DataPoint(...), DataPoint(...)]
>>> no_args()
[DataPoint(...), DataPoint(...)]
除了像run_in_executor(...)
这样不支持关键字参数的 API 之外,在传递函数时使用一些参数集而不使用其他参数集有时是很有用的,例如,不需要将数据库会话或 web 请求传递给每个函数。
Django’s ORM
许多从事 Web 工作的 Python 开发人员会在职业生涯的某个阶段使用 Django,他们可能想知道从异步代码(比如从通道)与 Django ORM 交互的等效过程是什么。
我对 Django 的建议是像平常一样使用 ORM,但是只能从同步函数中使用。您可以使用实用程序方法@channels.db.database_sync_to_async
调用同步函数,该方法可以用作同步函数的修饰器,使它们成为可调用的。这个装饰器通过一个显式的线程池委托给run_in_executor(...)
,但是也执行一些特定于 Django 的数据库连接管理。
from channels.db import database_sync_to_async
@database_sync_to_async
def handle_result(result: t.List[t.Dict[str, t.Any]]) -> t.List[DataPoint]:
points: t.List[DataPoints] = []
for data in result:
point = DataPoint(**data)
point.save()
points.append(point)
return points
如果在 Django 通道的上下文中使用假设的handle_result(...)
,前面的代码将是一个示例。由于 Django 强烈建议在给出响应之前提前执行所有的数据收集操作,这是一个次优但可行的解决方案。
查询数据
使用 SQLAlchemy 的 ORM 时,查询数据和接收 Python 对象是一件简单的事情。尽管如此,由于我们只使用了 SQLAlchemy 的查询构建和执行部分,这有点复杂。在支持 ORM 的 SQLAlchemy 中,我们会找到 PythonVersion 传感器的所有DataPoint
条目
db_session.query(DataPoint).filter(DataPoint.sensor_name=="PythonVersion")
但是我们需要使用 table 对象,并从c
属性中引用它的列,如下所示:
db_session.query(datapoint_table).filter(datapoint_table.c.sensor_name=="PythonVersion")
我们拿回来的对象不是DataPoint
对象,而是 SQLAlchemy 自己内部的命名元组实现,叫做轻量级命名元组。对于没有设置类映射器的任何查询,都将返回这些。
这些内部命名元组提供了一个_asdict()
方法,因此将result
对象转换为DataPoint
对象的最佳方式是DataPoint(**result._asdict()).
不幸的是,这些对象是动态生成的,被认为是 SQLAlchemy 的实现细节。因此,我们不能在函数的类型定义中使用这些对象。一旦我们添加了一个用于将命名元组转换为数据类的帮助器方法,我们的最终代码与清单 8-9 相同。
from dataclasses import dataclass, field, asdict
import datetime
import typing as t
@dataclass
class DataPoint:
sensor_name: str
data: t.Dict[str, t.Any]
id: int = None
collected_at: datetime.datetime = field(default_factory=datetime.datetime.now)
@classmethod
def from_sql_result(cls, result):
return cls(**result._asdict())
def _asdict(self):
data = asdict(self)
if data["id"] is None:
del data["id"]
return data
Listing 8-9Final implementation of DataPoint class that supports manual object mapping to SQLAlchemy
我们现在可以使用 SQLAlchemy 进行查询,这些查询返回我们的对象,但是结果对象与数据库没有任何直接连接,这可能会导致发出意外的查询。
results = map(
DataPoint.from_sql_result,
db_session.query(datapoint_table).filter(datapoint_table.c.sensor_name=="PythonVersion")
)
我们也可以在编写测试时使用这种方法,使它们几乎和使用 ORM 风格的相同代码一样清晰。
@pytest.mark.asyncio
async def test_datapoints_can_be_mapped_back_to_DataPoints(
self, mut, db_session, table, model
) -> None:
datapoints = await mut(db_session, ["http://localhost"], "")
db_points = [
model.from_sql_result(result) for result in db_session.query(table)
]
assert db_points == datapoints
Tip
如果您正在使用 Pandas 数据分析框架,DataFrame 对象提供了加载和存储来自 SQLAlchemy 查询的信息的专用方法。这些read_sql(...)
和to_sql(...)
方法在加载大型数据集时非常有用。
避免复杂的查询
经常可以看到人们在 ORM 中构建非常复杂的查询,比如涉及多个连接、 8 条件和子查询的查询。有几个技巧可以让我们更容易理解代表复杂条件的代码。对于 SQLAlchemy 来说,这是@hybrid_property
特性,而对于 Django 来说,这相当于定制查找和转换。
在第六章中,我们看了 SQLAlchemy 如何改变映射类中类属性的行为,使得列可以表示字段的值,或者 SQL 可以表示列,这取决于属性访问是在类的实例上进行的还是在类本身上进行的。混合属性允许将相同的方法扩展到您的定制逻辑。
这里的好处是重新组织代码,所以为了演示它在哪里有用,我们首先需要一个受益于重构的特性需求。我们很可能想要查看某一天常见值的汇总。显示传感器名称、它们的不同值以及今天发生的所有条目的值被看到的次数的查询可以在 SQLAlchemy 中表示为非常长的查询:
value_counts = (
db_session.query(
datapoint_table.c.sensor_name,
datapoint_table.c.data,
sqlalchemy.func.count(datapoint_table.c.id)
)
.filter(
sqlalchemy.cast(datapoint_table.c.collected_at, DATE)
== sqlalchemy.func.current_date()
)
.group_by(datapoint_table.c.sensor_name, datapoint_table.c.data)
)
这有几个问题。首先,name
和data
列出现了两次,因为我们希望根据它们进行分组,但是我们还需要能够看到哪个结果与哪个分组相关联,因此它们也必须出现在输出列中。其次,我们得到的过滤器很复杂,既要读取又要执行。读取很困难,因为它涉及到对 SQLAlchemy 函数的多次调用,而不是简单的比较。执行起来很困难,因为我们正在用强制转换修改collected_at
属性,这会使该列上的任何索引无效(如果我们已经设置了任何索引的话)。
Note
我用sqlalchemy.func.current_date()
来表示当前日期。数据库中任何可用的函数都可以通过sqlalchemy.func
按名称访问。这纯粹是一种风格选择;使用datetime.date.today()
或其他任何被数据库解释为日期的东西并不会更快或更慢。
查看 PostgreSQL 如何解释查询的最简单方法是打开一个数据库 shell,并在那里用EXPLAIN ANALYZE
修饰符运行查询。 9 输出格式相当复杂,但是有很多 PostgreSQL 的资源深入讲解了如何阅读它们以及优化方法。
目前,我们的目标是创建一个既易读又不会不必要地慢的查询。首先,让我们将公共列移到变量中以减少重复。
headers = datapoint_table.c.sensor_name, datapoint_table.c.data
value_counts = (
db_session.query(*headers, sqlalchemy.func.count(datapoint_table.c.id))
.filter(
sqlalchemy.cast(datapoint_table.c.collected_at, DATE)
== sqlalchemy.func.current_date()
)
.group_by(*headers)
)
这使得滤波器部分成为速度和可读性的瓶颈。我建议的下一步是在底层表中的collected_at
和sensor_name
字段上添加一些索引。我们通过将index=True
添加到表上的字段并生成一个新的 alembic 修订来实现这一点,如下所示:
datapoint_table = Table(
"datapoints",
metadata,
sqlalchemy.Column("id", sqlalchemy.Integer, primary_key=True),
sqlalchemy.Column("sensor_name", sqlalchemy.String, index=True),
sqlalchemy.Column("collected_at", TIMESTAMP, index=True),
sqlalchemy.Column("data", JSONB),
)
> pipenv run alembic revision --autogenerate -m "Add indexes to datapoints"
> pipenv run alembic upgrade head
不幸的是,这不足以改变我们的执行计划,因为作为比较的一部分,我们正在操作collected_at
列。这使得索引无效,因为CAST()
函数的结果不是索引可以缓存的操作之一。可以在数据库中创建一个函数,返回给定时间戳的日期,并对该函数的结果进行索引,但是这种方法不会使我们的代码更容易阅读。
相反,我建议使用@hybrid_property
将这个条件分解到类的一个属性中。我们可以复制相同的条件,但这只会使代码更容易阅读,而不是更有效地执行。将该条件分解出来的一个优点是可读性和效率之间的平衡发生了变化:如果它隐藏在一个具有有用名称的实用函数后面,而不是分散在整个代码库中,那么我们可以拥有一个更有效但可读性更差的条件。
除了具有可选的expression=
、update_expression=
和comparator=
属性之外,@hybrid_property
装饰器的工作方式与标准的@property
装饰器相似。一个expression
是一个类方法,它返回一个可选择的(即表示 SQLAlchemy 值的东西),比如CAST(datapoint_table.c.collected_at, DATE)
。update_expression
是一个类方法,它接受一个值并返回列的 2 元组列表和它们的新值,作为expression
的逆操作,允许更新列。这两种方法允许柱的外观与原生柱的行为相同。混合属性通常用于全名之类的东西,用来连接名和姓。 10 通常只有expression
被实现,而没有update_expression
。在这种情况下,该属性是只读的。
comparator
属性有一点不同:它不能与expression
或update_expression
特性结合使用,但是它允许实现更复杂的情况,比较操作符的两个部分都可以在发送到数据库之前定制。这种用法通常用于小写电子邮件地址或用户名,尽量使它们不区分大小写。 11
比较器和表达式不兼容的原因是,expression
特性是通过使用默认的比较器ExprComparator
实现的,所以我们不能提供自己的比较器,除非它覆盖处理expression
的代码。因为我们想要使用这两个特性,我们可以子类化ExprComparator
来使用它必须委托给表达式的能力,但是也覆盖比较器函数的实现。
我们可以创建一个@hybrid_property
,将日期时间转换为一个日期,同时使用一个定制的比较器来利用一些特定于数据库的优化。Postgres 将日期视为等同于时间部分为午夜的 datetime。我们可以确保右边是指定日期的午夜或更晚时间,并且在第二天的午夜之前,而不是确保比较的两边都是日期。我们可以通过确保比较的右边是一个日期并加 1 找到第二天来实现这一点。这允许我们使用索引进行两次比较,以获得与不使用索引的一次比较相同的结果。清单 8-10 中给出了更新的数据点实现。
from __future__ import annotations
from dataclasses import dataclass, field, asdict
import datetime
import typing as t
import sqlalchemy
from sqlalchemy.dialects.postgresql import JSONB, DATE, TIMESTAMP
from sqlalchemy.ext.hybrid import ExprComparator, hybrid_property
from sqlalchemy.orm import sessionmaker
from sqlalchemy.schema import Table
metadata = sqlalchemy.MetaData()
datapoint_table = Table(
"sensor_values",
metadata,
sqlalchemy.Column("id", sqlalchemy.Integer, primary_key=True),
sqlalchemy.Column("sensor_name", sqlalchemy.String, index=True),
sqlalchemy.Column("collected_at", TIMESTAMP, index=True),
sqlalchemy.Column("data", JSONB),
)
class DateEqualComparator(ExprComparator):
def __init__(self, fallback_expression, raw_expression):
# Do not try and find update expression from parent
super().__init__(None, fallback_expression, None)
self.raw_expression = raw_expression
def __eq__(self, other):
""" Returns True iff on the same day as other """
other_date = sqlalchemy.cast(other, DATE)
return sqlalchemy.and_(
self.raw_expression >= other_date,
self.raw_expression < other_date + 1,
)
def operate(self, op, *other, **kwargs):
other = [sqlalchemy.cast(date, DATE) for date in other]
return op(self.expression, *other, **kwargs)
def reverse_operate(self, op, other, **kwargs):
other = [sqlalchemy.cast(date, DATE) for date in other]
return op(other, self.expression, **kwargs)
@dataclass
class DataPoint:
sensor_name: str
data: t.Dict[str, t.Any]
id: t.Optional[int] = None
collected_at: datetime.datetime = field(default_factory=datetime.datetime.now)
@classmethod
def from_sql_result(cls, result) -> DataPoint:
return cls(**result._asdict())
def _asdict(self) -> t.Dict[str, t.Any]:
data = asdict(self)
if data["id"] is None:
del data["id"]
return data
@hybrid_property
def collected_on_date(self):
return self.collected_at.date()
@collected_on_date.comparator
def collected_on_date(cls):
return DateEqualComparator(
cls,
sqlalchemy.cast(datapoint_table.c.collected_at, DATE),
datapoint_table.c.collected_at,
)
Listing 8-10DataPoint table and model, with transparent optimized comparator for dates
ExprComparator
类型的构造函数有三个参数,模型类、表达式和混合属性。__init__(...)
中的class=
和hybrid_property=
参数用于实现更新行为,但是由于我们不需要这个特性,我们将简化接口并将None
传递给这些参数。expression 参数是我们希望用于查询和任何比较的参数(除非另有说明)。在__init__(...)
函数中,我们为底层列添加了一个新参数,这样我们就可以在自定义的比较函数中访问原始数据。
operate(...)
和reverse_operate(...)
函数实现了各种比较。它们允许对比较双方的参数进行操作,我们需要确保被比较的对象是 PostgreSQL 中的CAST()
到DATE
。__eq__(...)
方法是我们的自定义等式检查器,在这里我们实现了一个更有效的版本来检查两边是否是同一个日期,如前所述。
所有这些的效果是,我们可以无缝地比较两个 datetime 值,并获得正确的结果。两边都是CAST()
对DATE
,除非是相等检查(我们试图优化的检查),在这种情况下,只有参数是CAST()
对DATE
,允许左边的列使用索引。表 8-4 显示了可能的 Python 表达式、它们被翻译成的 SQL 或 Python,以及是否可以使用索引。
表 8-4
每个操作对混合属性的影响摘要
|Python 表达式
|
评估结果
|
使用的索引
|
| — | — | — |
| DataPoint.collected_on_date
| CAST(sensor_values.collected_at AS DATE)
| 不 |
| DataPoint(...).collected_on_date
| datetime.date(2020, 4, 1)
| 不适用(在 Python 中评估) |
| DataPoint.collected_on_date == other_date
| sensor_values.collected_at >= CAST(%(param_1)s AS DATE) AND sensor_values.collected_at < CAST(%(param_1)s AS DATE) + %(param_2)s
| 是(仅在处收集,不在右侧收集) |
| DataPoint.collected_on_date < other_date
| CAST(sensor_values.collected_at AS DATE) < CAST(%(param_1)s AS DATE)
| 不 |
| DataPoint(...).collected_on_date == other_date
| datetime.date(2020, 4, 1) == other_date
| 不适用(在 Python 中评估) |
| DataPoint(...).collected_on_date < other_date
| datetime.date(2020, 4, 1) < other_date
| 不适用的(用 Python 评估) |
有了这个collected_on_date
表达式和比较器,我们可以大大简化查询代码。当阅读代码时,使用这个作为条件更容易理解,并且我们已经确保生成了利用索引的高效 SQL。
headers = table.c.sensor_name, table.c.data
value_counts = (
db_session.query(*headers, sqlalchemy.func.count(table.c.id))
.filter(
model.collected_on_date == sqlalchemy.func.current_date()
)
.group_by(*headers)
)
Django’s ORM (Redux)
Django 的 ORM 以不同的方式处理这类问题,但是等效的功能确实存在。本小节给出了如何实现这一点的简要说明(对于已经熟悉 Django 的人来说)。有关更多详细信息,请查看本章末尾的其他资源。
Django 没有与@hybrid_property
或在变量中存储任意 SQL 结构等价的东西。使用查找和转换将代码分解成可重用的组件。
这些在查询中以类似于连接的方式被引用,所以如果前面的代码是 Django 模型,我们将能够使用
DataPoints.objects.filter(collected_at__date=datetime.date.today())
这在日期时间字段上使用内置的date
转换,将日期时间转换为一个日期。定义了一个转换器,用一个lookup_name
属性指定它可用的名称,用一个output_field
属性指定它创建的类型。它可以有一个function
属性(如果它直接映射到一个单参数数据库函数),或者它可以定义一个定制的as_sql(...)
方法。
查找的工作方式类似于转换器,但是它不能被链接,因此没有输出类型。它提供了一个lookup_name
属性和一个as_sql(...)
方法来生成相关的 SQL。这些也可以通过__name
访问,如果没有指定其他的,名为exact
的查找是默认的。
转换器和查找都需要注册才能使用。它们可以根据字段类型或另一个变压器进行注册。如果它们注册在一个字段上,它们将总是在任何具有该类型的表达式上可用,但是如果它们注册在一个转换器上,它们只有在紧跟转换器之后时才有效。我们可以通过在collected_at__date
中使用的TruncDate
转换器上定义一个自定义的exact
查找来构建一个自定义的等式检查,如清单 8-11 所示。每当我们使用datetimefield__date
时,这都适用,但在使用本地日期列时不适用。
from django.db import models
from django.db.models.functions.datetime import TruncDate
@TruncDate.register_lookup
class DateExact(models.Lookup):
lookup_name = 'exact'
def as_sql(self, compiler, connection):
# self.lhs (left-hand-side of the comparison) is always TruncDate, we # want its argument
underlying_dt = self.lhs.lhs
# Instead, we want to wrap the rhs with TruncDate
other_date = TruncDate(self.rhs)
# Compile both sides
lhs, lhs_params = compiler.compile(underlying_dt)
rhs, rhs_params = compiler.compile(other_date)
params = lhs_params + rhs_params + lhs_params + rhs_params
# Return ((lhs >= rhs) AND (lhs < rhs+1)) - compatible with # postgresql only!
return '%s >= %s AND %s < (%s + 1)' % (lhs, rhs, lhs, rhs), params
Listing 8-11Implementation of a date comparison in Django’s ORM
与 SQLAlchemy 版本一样,这允许在使用collected_at__date=datetime.date.today()
时进行高效的自定义查找,但是对于collected_at__date__le==datetime.date.today()
和其他比较,会退回到效率较低的强制转换行为。
根据视图查询
在整个代码库中,很多地方都需要一个很难用 ORM 表示的查询。由于指定连接的方式,这在使用 Django ORM 时稍微常见一些,但是在使用 SQLAlchemy 时确实会发生。一个典型的例子是关联一个表中的多行,特别是按日期或地理位置,而不是与另一个表中的一行相关。例如,一个存储用户和旅行计划并希望查询在给定日期哪些用户对彼此接近的数据库很难在 ORM 中表示。
在这种情况下,您可能会发现创建数据库视图并对其进行查询更容易。它不会改变性能特征, 12 ,但确实允许将复杂的查询像表一样处理,大大简化了等式的 Python 一侧。
SQLAlchemy 支持从视图派生的表,因此我们可以使用我们之前创建的查询,将其转换为视图,然后将其作为表映射回 SQLAlchemy。我们可以在数据库控制台中手动创建视图,但是我建议创建一个新的 alembic 版本来发出CREATE VIEW
语句,这样它就可以更容易地跨实例部署。创建不带--autogenerate
标志的 alembic 版本,并修改结果文件,如清单 8-12 所示。
"""Add daily summary view
Revision ID: 6962f8455a6d
Revises: 4b2df8a6e1ce
Create Date: 2019-12-03 11:50:24.403402
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = "6962f8455a6d"
down_revision = "4b2df8a6e1ce"
branch_labels = None
depends_on = None
def upgrade():
create_view = """
CREATE VIEW daily_summary AS
SELECT
datapoints.sensor_name AS sensor_name,
datapoints.data AS data,
count(datapoints.id) AS count
FROM datapoints
WHERE
datapoints.collected_at >= CAST(CURRENT_DATE AS DATE)
AND
datapoints.collected_at < CAST(CURRENT_DATE AS DATE) + 1
GROUP BY
datapoints.sensor_name,
datapoints.data;
"""
op.execute(create_view)
def downgrade():
op.execute("""DROP VIEW daily_summary""")
Listing 8-12New migration to add a view with raw SQL
我们现在可以创建一个表对象来引用这个视图,允许我们在 SQLAlchemy 中生成查询:
daily_summary_view = Table(
"daily_summary",
metadata,
sqlalchemy.Column("sensor_name", sqlalchemy.String),
sqlalchemy.Column("data", JSONB),
sqlalchemy.Column("count", sqlalchemy.Integer),
info={"is_view": True},
)
info 行允许我们设置任意的元数据。在这种情况下,在env.py
文件中使用is_view
元数据来配置 alembic,以便在自动生成修订时忽略带有该标记的表。如果没有这些,alembic 将试图创建与我们的视图相冲突的匹配表。需要修改env.py
文件以包含清单 8-13 中给出的函数,并且两个context.configure(...)
函数调用必须将include_object=include_object
添加到参数中。
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
from apd.aggregation.database import metadata as target_metadata
def include_object(object, name, type_, reflected, compare_to):
if object.info.get("is_view", False):
return False
return True
def run_migrations_online():
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
include_object=include_object,
)
with context.begin_transaction():
context.run_migrations()
Listing 8-13Changes to env.py to enable Table objects to represent views
通过前面的更改,在执行相同的 SQL 语句时,可以将汇总 SQL 语句简化为db_session.query(daily_summary_view)
。每次使用视图时,都应该仔细考虑这种变化。在 SQL 语句上使用视图通常不会更清楚,但是对于更复杂的查询,我建议您记住这种未被充分利用的技术。
可供选择的事物
对于在异步上下文中与 SQL 数据库进行交互,我推荐部分使用 SQLAlchemy,但这还不够完美。根据您的使用情况,有一些替代方法可能是合适的。
还有一些 async-native ORM 正在开发中,比如乌龟 ORM 。它从根本上支持 asyncio,所以它不会遇到 SQLAlchemy 遇到的潜在阻塞问题。它目前是一个年轻的项目,所以虽然它是一个有趣的方法,我会继续关注它,但我现在不能推荐它用于生产代码。
另一种方法是使用类似于 asyncpg 的工具降低到较低的数据库集成级别。这允许与数据库进行完全异步的交互,而不需要将工作交给线程。缺点是没有内置的 SQL 生成器,所以它明显不太用户友好,并且您更容易出错。一些需要特别快的数据库连接的简单应用确实使用了这种方法,但是我不建议在一般情况下使用这种方法。
最后,对于 SQLAlchemy 导致查询阻塞的风险,有一种实用的方法,我在本章前面提到过。有时,最好的解决方案是接受风险,因为使用 SQLAlchemy 的好处自然会超过性能损失的后果。这在服务器端应用中是绝对不可接受的,在服务器端应用中,阻塞和减速会导致客户端性能严重下降,但是在客户端应用中,使用 asyncio 来提高代码的性能(否则代码将是单线程的),只使用 SQLAlchemy 并尽最大努力在执行器中运行阻塞代码几乎没有什么负面影响。
异步代码中的全局变量
尤其是在 web 开发中,经常会发现自己处于这样一种情况:你总是需要访问一个特定的对象,这意味着你所有的函数都需要将这个对象作为参数。这通常是请求对象,表示服务器当前正在处理的 HTTP 请求。还有一个配置对象也很常见,在我们的异步代码中,我们发现自己向许多函数签名添加了一个ClientSession
对象,而不是为每个 HTTP 请求实例化一个新的对象。
所有这些都是全局变量的概念吸引人的地方。Django 和 Flask 都提供了访问配置的全局方式(django.settings
和flask.current_app.config
),Flask 还通过flask.request
提供请求。
你经常听到人们批评使用全局变量的代码,说这证明你的应用没有被正确设计。我采取了一种更务实的观点:几乎每个函数潜在需要的对象不应该存在,但有时它们会存在。因此,它们应该是全局可用的,以防止它们污染整个系统的函数签名。
让我们使用 Python 的contextvars
特性使我们的ClientSession
对象成为这些全局可用的项目之一。上下文变量是线程局部变量思想的发展:变量是全局范围的,但是对于不同的并发代码可以有不同的值。通过threading.Local()
创建的线程局部变量允许通过属性访问来存储和检索任意数据,但只能在一个线程内。任何其他并发线程将看不到其他线程存储的数据;每个线程都可以有自己的变量值。
我们的代码不是线程化的;它使用异步函数调用来引入并发性,因此线程局部变量将总是向所有并发任务显示相同的数据。这就是上下文变量有用的地方;它们为任意范围的值提供相同的范围,而不是将范围限制为总是当前线程。
上下文变量是用contextvars.ContextVar(...)
构造函数定义的,它将变量的名称作为参数。
from contextvars import ContextVar
import aiohttp
http_session_var: ContextVar[aiohttp.ClientSession] = ContextVar("http_session")
ContextVar
对象不直接存储值;它静默地委托给上下文对象。您可以手动实例化上下文对象,并使用该上下文执行一个函数,但是不需要使用异步代码来执行。 13 每当一个协程被调度为一个任务时,就会分配一个新的上下文,并从父任务的上下文中复制值。
可以使用set(...)
方法为ContextVar
设置值,并使用get()
方法检索值。如果一段代码试图在当前上下文中没有设置的上下文变量上调用get()
,就会引发 LookupError。必要的修改如表 8-5 所示。
表 8-5
get_data_points(…)所以 HTTP 客户端是作为上下文变量而不是参数传递的
| `http = http_session_var.get()``to_get = http.get(url, headers=headers)``async with to_get as request:``result = await request.json()``ok = request.status == 200` | `async with aiohttp.ClientSession() as http:``http_session_var.set(http)``tasks = [``get_data_points(server, api_key)``for server in servers``]` |也可以使用set(...)
的返回值临时覆盖上下文变量的值。这通常是不必要的,但是如果您确实需要在协程中更改一个变量,然后再将它改回来,那么这是首选模式:
reset_token = http_session_var.set(mockclient)
try:
datapoints = await get_data_points("http://localhost", "")
finally:
http_session_var.reset(reset_token)
Exercise 8-1: Extending The API
本章介绍了许多新概念,并涉及一些复杂的测试设置。这段代码很复杂,但是我们需要有信心在新版本发布时更新它。
目前,除了传感器的 URL 之外,我们没有传感器的任何标识符,并且随着 IP 地址的重新分配,这种标识符会随着时间而改变。我们应该创建一种识别传感器端点的方法,这样我们就可以更容易地从单个传感器中找到数据。向提供新端点的 apd.sensors 包添加新的 v2.1 API。这个端点应该是
@version.route("/deployment_id")
def deployment_id() -> t.Tuple[t.Dict[str, t.Any], int, t.Dict[str, str]]:
headers = {"Content-Security-Policy": "default-src 'none'"}
data = {"deployment_id": flask.current_app.config["APD_SENSORS_DEPLOYMENT_ID"]}
return data, 200, headers
您将需要修改测试设置的许多部分来适应这种变化,包括以前 API 的 fixture 代码。记住,目的不是让旧 API 的测试代码永远不变,只是面向用户的 API 本身。
一旦完成了这些,更新 apd.aggregation 包以将deployment_id
存储为DataPoint
的属性,并使用 v2.1 API 从端点检索部署 ID。
这是一个显著的变化,相当于 apd.sensors 包的一个主要版本,也可能是本书中最困难的练习。然而,这是您迟早要在实际代码中进行的那种更改,所以练习一下是有好处的。
这两个更改的完整版本都在本章附带的代码中。
摘要
在本章中,我们已经讨论了运行异步代码的许多实际问题,尤其是在异步环境中使用数据库时可能会遇到的一些困难。要记住的最重要的事情是,无论是处理 SQLAlchemy、Django ORM,还是连接到另一个使用同步代码的数据库类型,run_in_executor 模式都是必要的,以避免显著降低性能的阻塞行为。但是,需要在性能优势和代码可读性优势之间取得平衡。这可能是您在编写异步代码时应该记住的最重要的平衡。
我们还讨论了许多在编写 Python 代码时通常有用的技术,无论是异步的还是其他的。使用contextlib
的定制数据类和上下文管理器是非常有用的功能,您将在许多不同的上下文中使用它们。上下文变量和高效的 ORM 查询都非常有用,但程度较低。
在本章的过程中,apd.aggregation
包已经成长了很多,达到了足以在生产中使用的质量。在下一章,我们将着眼于分析数据和构建有用的用户界面来显示报告。
额外资源
我推荐以下资源,以获取本章所涵盖主题的更多信息:
-
有关在 Django 的 ORM 中实现自定义 SQL 行为的信息,请参见
https://docs.djangoproject.com/en/3.0/ref/models/expressions/
。 -
关于混合属性的完整 SQLAlchemy 文档,包括一些不常用特性的信息,位于
https://docs.sqlalchemy.org/en/14/orm/extensions/hybrid.html
。 -
Django 关于混合同步和异步代码的文档在
https://docs.djangoproject.com/en/3.0/topics/async/
中,其中包括数据库操作和帮助器函数的信息,用于在 Django 应用中弥补同步和异步代码之间的差距。 -
位于
https://explain.depesz.com/
的 web 应用是一个有用的工具,通过将 PostgreSQL EXPLAIN ANALYZE 语句重新格式化为表格并对计时信息进行颜色编码,可以帮助理解它们的结果。 -
https://github.com/getsentry/responses
是一个有用的库,用于在使用请求 HTTP 库时创建模拟 HTTP 响应。
在启动线程之前,可以使用thread_obj.daemon = True
将它标记为“守护”线程。这将允许进程在线程仍在运行的情况下结束,但这会导致线程在操作中途终止。通常最好使用 sentinel 值来允许所有线程干净地关闭。
2
返回一个迭代器,依次包含每个迭代器中的项目。
3
这是通过标准库中的inspect.isgeneratorfunction(...)
函数完成的。
4
package fixture 作用域目前是试验性的,可能会在 pytest 的未来版本中删除。我最常用的作用域依次是函数、会话、类和模块。我还没有理由使用包范围。
5
如果您想亲自看到这一点,可以在 fixtures 中添加print(...)
调用,并使用-s
开关运行 pytest,以防止捕获 stdout。但是,请注意,pytest 不保证它决定运行测试的顺序,因此这对于调试问题比验证没有问题发生更有用。
6
response['body']
行不通。
7
需要强调的是,这些测试功能是为了补充功能测试套件,而不是取代它。我们的测试运行不会更快,除非我们排除功能测试。
8
有时甚至多次涉及同一个表的连接,这会导致特别混乱的代码。
9
如果您的查询涉及大量参数,这可能会很棘手。sqlalchemy-utils 包中有一个名为 analyze 的函数将执行分析,但它也解析结果,而不是显示标准格式。下面的(相当复杂的)一行程序,当放在您的.pdbrc
文件中时,将允许您从 pdb 提示符下运行解释分析查询:
(Pdb) explain_analyze example_query db_session
GroupAggregate (cost=25.61..25.63 rows=1 width=72) (actual time=0.022..0.022 rows=0 loops=1)
Group Key: sensor_name, data
-> Sort (cost=25.61..25.62 rows=1 width=68) (actual time=0.022..0.022 rows=0 loops=1)
Sort Key: data
Sort Method: quicksort Memory: 25kB
-> Seq Scan on sensor_values (cost=0.00..25.60 rows=1 width=68) (actual time=0.018..0.018 rows=0 loops=1)
Filter: (((sensor_name)::text = 'ACStatus'::text) AND ((collected_at)::date = CURRENT_DATE))
Planning Time: 1.867 ms
Execution Time: 0.063 ms
alias explain_analyze !_compiled=(%1).selectable.compile();_rows=(%2).execute("EXPLAIN ANALYZE "+ str(_compiled), params=_compiled.params); print("\n".join(str(_row[0]) for _row in _rows))
and used as follows:
我已经将它包含在从本章开始的项目.pdbrc
中,所以如果你跟随附带的代码,它将对你可用。
10
这是常用的,但请不要这样做。不是每个人都有姓和名;没有一种通用的方法可以把一个全名分成几个部分,也没有办法把几个部分组合成一个全名。另见《程序员相信名字的假话》(及相关文章, …时间, …地址, …地图, …性别等。).作为工程师,我们有责任指出这些缺陷,就像我们在 20 世纪 90 年代指出两位数日期的缺陷一样。
11
这些比较器只有在被 SQLAlchemy 查询时才起作用;它们不会改变数据库中唯一约束的行为。您将需要确保这些约束也是正确的,例如通过将它们指定为
Index("unique_username_idx", func.lower(user_table.c.username), unique=True)
12
除非 postgresql 的物化视图特性,该特性在显式刷新之前缓存其结果。
13
对于同步代码,可以创建一个新的上下文,并调用一个使用该上下文的函数
context = contextvars.copy_context()
context.run(your_callable)
九、查看数据
在前一章的结尾,我们开始研究我们可能感兴趣的查询类型,但是我们还没有编写任何例程来帮助我们理解我们正在收集的数据。在这一章中,我们将回到 Jupyter 笔记本,这一次是作为数据分析工具,而不是原型制作工具。
IPython 和 Jupyter 无缝支持同步和异步函数调用。我们在这两种类型的 API 之间有一个(大部分)自由的选择。由于apd.aggregation
包的其余部分是异步的,我建议我们创建一些实用程序协程来提取和分析数据。
查询功能
Jupyter 笔记本可以自由地导入和使用 SQLAlchemy 函数,但这需要用户了解大量关于聚合系统数据结构的内部信息。这实际上意味着我们已经创建的表和模型成为公共 API 的一部分,对它们的任何更改都可能意味着增加主版本号并为最终用户记录更改。
相反,让我们创建一些返回DataPoint
记录供用户交互的函数。这样,只有DataPoint
对象和函数签名是我们必须为人们维护的 API 的一部分。随着我们发现额外的需求,我们可以随时添加更多的功能。
首先,我们需要的最重要的特性是找到数据记录的能力,这些数据记录是按照收集时间排序的。这允许用户编写一些分析代码来分析传感器随时间变化的值。我们可能还想通过传感器类型、部署标识符和日期范围对此进行过滤。
我们必须决定函数的形式。它应该返回对象的列表或元组还是迭代器?元组可以让我们轻松地计算检索到的条目数,并多次遍历列表。另一方面,迭代器将允许我们最小化 RAM 的使用,这可能有助于我们支持更大的数据集,但限制我们只能迭代一次数据。我们将创建迭代器函数,因为它们允许更高效的代码。迭代器可以被调用代码转换成元组,所以我们的用户可以选择迭代元组。
在编写这个函数之前,我们需要一种方法让用户建立数据库连接。因为我们的目标之一是对我们的最终用户隐藏数据库的细节,所以我们不想要求为此使用 SQLAlchemy 函数。我们创建的用于连接数据库的定制函数(清单 9-1 )也可以设置上下文变量来表示我们的连接,从而避免了对所有搜索函数的显式会话参数的需求。
import contextlib
from contextvars import ContextVar
import functools
import typing as t
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.orm.session import Session
db_session_var: ContextVar[Session] = ContextVar("db_session")
@contextlib.contextmanager
def with_database(uri: t.Optional[str] = None) -> t.Iterator[Session]:
"""Given a URI, set up a DB connection, and return a Session as a context manager """
if uri is None:
uri = "postgresql+psycopg2://localhost/apd"
engine = create_engine(uri)
sm = sessionmaker(engine)
Session = sm()
token = db_session_var.set(Session)
try:
yield Session
Session.commit()
finally:
db_session_var.reset(token)
Session.close()
Listing 9-1query.py with a context manager to connect to the database
该函数充当(同步)上下文管理器,建立数据库连接和相关会话,在进入相关with
块的主体之前,返回该会话并将其设置为db_session_var
上下文变量的值。当上下文管理器退出时,它还会取消设置此会话,提交所有更改,并关闭会话。这确保了数据库中没有延迟锁,数据是持久的,并且使用db_session_var
变量的函数只能在上下文管理器的主体中使用。
如果我们确保已经安装了聚合包的环境在 Jupyter 中注册为内核,我们就可以开始在笔记本中编写实用函数了。我还建议安装一些助手包,这样我们可以更容易地可视化结果。
> pipenv install ipython matplotlib
> pipenv run ipython kernel install --user --name="apd.aggregation"
我们现在可以启动一个新的 Jupyter 笔记本(清单 9-2 ,选择apd.aggregation
内核并连接到数据库,使用新的with_database(...)
装饰器。为了测试连接,我们可以使用产生的会话和我们的datapoint_table
对象手动查询数据库。
from apd.aggregation.query import with_database
from apd.aggregation.database import datapoint_table
with with_database("postgresql+psycopg2://apd@localhost/apd") as session:
print(session.query(datapoint_table).count())
Listing 9-2Jupyter cell to find number of sensor records
我们还需要编写返回DataPoint
对象供用户分析的函数。最终,我们将不得不处理由于处理大量数据而导致的性能问题,但是您为解决问题而编写的第一个代码不应该被优化,一个简单的实现既更容易理解,也更可能不会因为太聪明而受到影响*。我们将在下一章研究一些优化技术。*
*Premature Optimization
调试比一开始写代码要难两倍。因此,如果你尽可能聪明地编写代码,从定义上来说,你没有足够的聪明去调试它。
-布莱恩·金格
Python 不是最快的编程语言;编写代码来最小化固有的缓慢可能很诱人,但是我强烈建议抵制这种冲动。我见过“高度优化”的代码需要一个小时来执行,而当替换为相同逻辑的简单实现时,只需要两分钟就可以完成。
这并不常见,但是当你使你的代码更加精细时,你的工作就变得更加困难。
如果您编写了一个方法的最简单版本,您可以将其与后续版本进行比较,以确定您是在使代码变得更快还是更复杂。
我们将实现的第一个版本的get_data()
返回数据库中所有的DataPoint
对象,而不必担心处理任何 SQLAlchemy 对象。我们已经决定创建一个生成器协程,而不是一个返回DataPoint
对象列表的函数(或协程),所以我们最初的实现是清单 9-3 中的那个。
async def get_data() -> t.AsyncIterator[DataPoint]:
db_session = db_session_var.get()
loop = asyncio.get_running_loop()
query = db_session.query(datapoint_table)
rows = await loop.run_in_executor(None, query.all)
for row in rows:
yield DataPoint.from_sql_result(row)
Listing 9-3Simplest implementation of get_data()
该函数从由with_database(...)
设置的上下文变量中获取会话,构建查询对象,然后使用执行器运行该对象的 all 方法,在 all 方法运行时让位于其他任务。迭代查询对象而不是调用query.all()
会导致循环运行时触发数据库操作,所以我们必须小心,只在异步代码中设置查询,并将all()
函数调用委托给执行器。这样做的结果是一个 SQLAlchemy 的轻量级结果列表,名为 rows 变量中的 tuples,然后我们可以对其进行迭代,产生匹配的DataPoint
对象。
由于rows
变量包含所有结果对象的列表,我们知道在执行返回到我们的get_data()
函数之前,所有数据都已经被数据库处理过,并在执行器中被解析为 SQLAlchemy。这意味着在第一个DataPoint
对象对最终用户可用之前,我们使用了存储完整结果集所需的所有 RAM。当我们不知道我们是否需要所有这些数据时,存储所有这些数据是有点内存和时间效率低下的,但是在迭代器中对数据进行分页的复杂方法将是过早优化的一个例子。不要改变这种天真的方法,直到它成为一个问题。
我们总是不得不处理检索 SQLAlchemy 行对象的内存和时间开销,但是表 9-1 中的数字让我们知道通过将它们转换成DataPoint
类我们给系统增加了多少开销。一百万行将涉及额外的 152 兆字节的 RAM 和额外的 1.5 秒的处理时间。这两者都在现代计算机的能力范围之内,并且适合于不经常执行的任务,所以它们不是当前的问题。
表 9-1
SQLAlchemy 行和我们的 DataPoint 类的 RAM 使用和实例化时间的比较
|目标
|
尺寸 1
|
时间实例化 2
|
| — | — | — |
| SQLAlchemy 结果行 | 80 字节 | 0.4 微秒 |
| 数据点 | 152 字节 | 1.5 微秒 |
*结果可能因 Python 实现和可用处理能力而异
然而,因为我们正在创建一个迭代器,所以不能保证我们的DataPoint
对象会立刻驻留在内存中。如果消费代码没有保存对它们的引用,那么在它们被使用后,它们可以立即被垃圾回收。例如,在清单 9-4 中,我们使用两个新的助手函数来计算行数,而没有任何数据点对象驻留在内存中。
from apd.aggregation.query import with_database, get_data
with with_database("postgresql+psycopg2://apd@localhost/apd") as session:
count = 0
async for datapoint in get_data():
count += 1
print(count)
Listing 9-4Jupyter cell to count data points using our helper context manager
仅仅计算数据点并不是分析数据的有趣方式。我们可以开始尝试通过在散点图上绘制数值来理解这些数据。让我们从一个简单的健全性检查开始,绘制出RelativeHumidity
传感器的值与日期的关系(列表 9-5 )。这是一个很好的开始,因为存储的数据是浮点数而不是基于字典的结构,所以我们不需要解析值。
matplotlib 库可能是 Python 中最流行的绘图库。它的plot_date(...)
函数非常适合绘制一系列随时间变化的数值。它需要 x 轴的值列表和 y 轴的相应值列表,以及在绘制点 3 时使用的样式和一个标志来设置哪个轴包含日期值。我们的get_data(...)
函数不直接返回我们需要的 x 和 y 参数,它返回数据点对象的异步迭代器。
我们可以使用 list comprehension 将数据点对象的异步 iterable 转换为包含来自单个传感器的日期和值对的元组列表。此时,我们有了一个日期和值对的列表,可以使用内置的zip(...)
4 函数将分组转换为一对列表,一个用于日期,另一个用于值。
from apd.aggregation.query import with_database, get_data
from matplotlib import pyplot as plt
async def plot():
points = [
(dp.collected_at, dp.data)
async for dp in get_data()
if dp.sensor_name=="RelativeHumidity"
]
x, y = zip(*points)
plt.plot_date(x, y, "o", xdate=True)
with with_database("postgresql+psycopg2://apd@localhost/apd") as session:
await plot()
plt.show()
Listing 9-5Relative humidity plotting jupyter cell, with the output chart it generates
过滤数据
最好在查询阶段过滤数据,而不是在迭代时丢弃所有不符合我们标准的传感器数据。现在,选择每一条数据,创建一个结果对象,然后是一个DataPoint
对象,只有这样才跳过不相关的条目。为此,我们可以向get_data(...)
方法添加一个额外的参数,该参数决定是否将sensor_data
上的过滤器应用于生成的查询。
async def get_data(sensor_name: t.Optional[str] = None) -> t.AsyncIterator[DataPoint]:
db_session = db_session_var.get()
loop = asyncio.get_running_loop()
query = db_session.query(datapoint_table)
if sensor_name:
query = query.filter(datapoint_table.c.sensor_name == sensor_name)
query = query.order_by(datapoint_table.c.collected_at)
这种方法节省了大量开销,因为这意味着只有相关的传感器数据点被传递给最终用户,而且这是一个更自然的接口。用户希望能够指定他们想要的数据,而不是绝对获取所有数据并手动过滤。清单 9-6 中的函数版本用不到一秒的时间来执行我的样本数据集(相比之下,前一版本用了 3 秒多),但显示的是相同的图表。
from apd.aggregation.query import with_database, get_data
from matplotlib import pyplot as plt
async def plot():
points = [(dp.collected_at, dp.data) async for dp in get_data(sensor_name="RelativeHumidity")]
x, y = zip(*points)
plt.plot_date(x, y, "o", xdate=True)
with with_database("postgresql+psycopg2://apd@localhost/apd") as session:
await plot()
plt.show()
Listing 9-6Delegating filtering to the get_data function
这个绘图函数很短,不太复杂;它代表了从数据库加载数据的一个非常自然的接口。不利的一面是,将多个部署混合在一起会导致图表不清晰,因为给定时间有多个数据点。Matplotlib 支持用不同的逻辑结果集多次调用plot_date(...)
,然后用不同的颜色显示。我们的用户可以通过在迭代get_data(...)
调用的结果时创建多个点列表来实现这一点,如清单 9-7 所示。
import collections
from apd.aggregation.query import with_database, get_data
from matplotlib import pyplot as plt
async def plot():
legends = collections.defaultdict(list)
async for dp in get_data(sensor_name="RelativeHumidity"):
legends[dp.deployment_id].append((dp.collected_at, dp.data))
for deployment_id, points in legends.items():
x, y = zip(*points)
plt.plot_date(x, y, "o", xdate=True)
with with_database("postgresql+psycopg2://apd@localhost/apd") as session:
await plot()
plt.show()
Listing 9-7Plotting all sensor deployments independently
这又使得界面不自然;对于最终用户来说,更合理的做法是迭代部署,然后迭代传感器数据值,而不是迭代所有数据点,然后手动将它们组织到列表中。另一种方法是创建一个列出所有部署 id 的新函数,然后允许get_data(...)
通过deployment_id
进行过滤。这将允许我们遍历单个部署,并进行新的get_data(...)
调用以仅获取该部署的数据。清单 9-8 展示了这一点。
async def get_deployment_ids():
db_session = db_session_var.get()
loop = asyncio.get_running_loop()
query = db_session.query(datapoint_table.c.deployment_id).distinct()
return [row.deployment_id for row in await loop.run_in_executor(None, query.all)]
async def get_data(
sensor_name: t.Optional[str] = None,
deployment_id: t.Optional[UUID] = None,
) -> t.AsyncIterator[DataPoint]:
db_session = db_session_var.get()
loop = asyncio.get_running_loop()
query = db_session.query(datapoint_table)
if sensor_name:
query = query.filter(datapoint_table.c.sensor_name == sensor_name)
if deployment_id:
query = query.filter(datapoint_table.c.deployment_id == deployment_id)
query = query.order_by(
datapoint_table.c.collected_at,
)
Listing 9-8Extended data collection functions for deployment_id filtering
这个新函数可用于循环多个对get_data(...)
的调用,而不是 plot 函数循环并将结果数据点分类到独立的列表中。清单 9-9 展示了一个非常自然的接口,用于循环单个传感器的所有部署,其行为与之前的版本相同。
import collections
from apd.aggregation.query import with_database, get_data, get_deployment_ids
from matplotlib import pyplot as plt
async def plot(deployment_id):
points = []
async for dp in get_data(sensor_name="RelativeHumidity", deployment_id=deployment_id):
points.append((dp.collected_at, dp.data))
x, y = zip(*points)
plt.plot_date(x, y, "o", xdate=True)
with with_database("postgresql+psycopg2://apd@localhost/apd") as session:
deployment_ids = await get_deployment_ids()
for deployment in deployment_ids:
await plot(deployment)
plt.show()
Listing 9-9Plotting all deploymens using the new helper functions
这种方法允许最终用户单独询问每个部署,因此一次只有传感器和部署组合的相关数据被载入 RAM。这是一个非常适合提供给最终用户的 API。
多级迭代器
我们之前修改了按传感器名称过滤的界面,以在数据库中进行过滤,从而避免重复不必要的数据。我们新的部署 id 过滤器不是用来排除我们不需要的数据,而是用来使独立遍历每个逻辑组变得更容易。我们不需要在这里使用过滤器,我们正在使用一个使界面更加自然。
如果您经常使用标准库中的itertools
模块,您可能已经使用过groupby(...)
函数。它接受一个迭代器和一个键函数,并返回一个迭代器的迭代器,第一个迭代器是键函数的值,第二个迭代器是与键函数的给定结果匹配的一系列值。这就是我们一直试图通过列出我们的部署,然后过滤数据库查询来解决的问题。
给groupby(...)
的关键函数通常是一个简单的 lambda 表达式,但它可以是任何函数,比如来自操作符模块的函数之一。比如operator.attrgetter("deployment_id")
相当于lambda obj: obj.deployment_id
,operator.itemgetter(2)
相当于lambda obj: obj[2].
对于这个例子,我们将定义一个键函数,它返回一个模为 3 的整数的值,以及一个data()
生成器函数,它产生一系列固定的数字,并在这个过程中打印它的状态。这让我们可以清楚地看到底层迭代器是何时高级的。
import itertools
import typing as t
def mod3(n: int) -> int:
return n % 3
def data() -> t.Iterable[int]:
for number in [0, 1, 4, 7, 2, 6, 9]:
print(f"Yielding {number}")
yield number
我们可以遍历 data()生成器的内容并打印 mod3 函数的值,这让我们看到第一组有一个项目,然后是一组三个项目,然后是一组一个项目,然后是一组两个项目。
>>> print([mod3(number) for number in data()])
data() is starting
Yielding 0
Yielding 1
Yielding 4
Yielding 7
Yielding 2
Yielding 6
Yielding 9
data() is complete
[0, 1, 1, 1, 2, 0, 0]
设置 groupby 不会消耗基础 iterable 当 groupby 被迭代时,它生成的每个项目都被处理。为了正确工作,groupby 只需要确定当前项是否与前一个项在同一个组中,或者是否有新的组开始,它不需要将 iterable 作为一个整体来分析。对于 key 函数来说,具有相同值的项只有在它们是输入迭代器中的连续块时才会被分组在一起,所以通常要确保底层迭代器是有序的,以避免分组。
通过用mod3(...)
key 函数在我们的数据上创建一个 groupby,我们可以创建一个两级循环,首先迭代 key 函数的值,然后迭代产生那个键值的来自data()
的值。
>>> for val, group in itertools.groupby(data(), mod3):
... print(f"Starting new group where mod3(x)=={val}")
... for number in group:
... print(f"x=={number} mod3(x)=={mod3(val)}")
... print(f"Group with mod3(x)=={val} is complete")
...
data() is starting
Yielding 0
Starting new group where mod3(x)==0
x==0 mod3(x)==0
Yielding 1
Group with mod3(x)==0 is complete
Starting new group where mod3(x)==1
x==1 mod3(x)==1
Yielding 4
x==4 mod3(x)==1
Yielding 7
x==7 mod3(x)==1
Yielding 2
Group with mod3(x)==1 is complete
Starting new group where mod3(x)==2
x==2 mod3(x)==2
Yielding 6
Group with mod3(x)==2 is complete
Starting new group where mod3(x)==0
x==6 mod3(x)==0
Yielding 9
x==9 mod3(x)==0
data() is complete
Group with mod3(x)==0 is complete
从 print 语句的输出中,我们可以看到 groupby 一次只提取一项,但是它管理迭代器的方式使得对值的循环很自然。每当内部循环请求一个新项时,groupby 函数都会从底层迭代器请求一个新项,然后根据该值决定其行为。如果 key 函数报告与前一项相同的值,它会向内部循环产生新值;否则,它表示内部循环完成,并保持该值,直到下一个内部循环开始。
如果我们有具体的条目列表,迭代器的行为就像我们预期的一样;如果不需要,就不需要迭代内部循环。如果我们在推进外循环之前没有完全迭代内循环,groupby 对象将透明地推进源 iterable,就像我们已经做的那样。在下面的例子中,我们跳过了三个 where mod3(...)==1
的组,我们可以看到底层迭代器被 groupby 对象推进了三次:
>>> for val, group in itertools.groupby(data(), mod3):
... print(f"Starting new group where mod3(x)=={val}")
... if val == 1:
... # Skip the ones
... print("Skipping group")
... continue
... for number in group:
... print(f"x=={number} mod3(x)=={mod3(val)}")
... print(f"Group with mod3(x)=={val} is complete")
...
data() is starting
Yielding 0
Starting new group where mod3(x)==0
x==0 mod3(x)==0
Yielding 1
Group with mod3(x)==0 is complete
Starting new group where mod3(x)==1
Skipping group
Yielding 4
Yielding 7
Yielding 2
Starting new group where mod3(x)==2
x==2 mod3(x)==2
Yielding 6
Group with mod3(x)==2 is complete
Starting new group where mod3(x)==0
x==6 mod3(x)==0
Yielding 9
x==9 mod3(x)==0
data() is complete
Group with mod3(x)==0 is complete
当我们使用它时,行为是直观的,但是很难理解它是如何实现的。图 9-1 显示了一对流程图,一个用于外部循环,一个用于每个单独的内部循环。
图 9-1
演示 groupby 如何工作的流程图
如果我们有一个标准迭代器(与异步迭代器相反),我们可以通过deployment_id
对数据进行排序,并使用itertools.groupby(...)
来简化我们的代码以处理多个部署,而不需要查询单个部署。我们可以遍历这些组,并使用列表理解和zip(...)
,以我们已经使用的相同方式处理内部迭代器,而不是对每个组进行新的get_data(...)
调用。
不幸的是,在撰写本文时,groupby 还没有完全异步的对等物。虽然我们可以编写一个函数来返回一个异步迭代器,它的值是数据点对的 UUID 和异步迭代器,但是没有办法将它们自动分组。
冒着编写聪明代码的风险,我们可以使用闭包编写一个自己处理异步代码的 groupby 实现。它将向最终用户公开多个迭代器,这些迭代器在同一个底层迭代器上工作,就像itertools.groupby(...)
一样。如果有可用的库函数,最好使用库函数。
每当我们发现 key 函数的一个新值时,我们需要返回一个新的生成器函数,它维护了对底层源迭代器的引用。这样,当有人推进一个项迭代器时,它可以选择要么产生它接收的数据点,要么指示它是项迭代器的结尾,就像 groupby 函数所做的那样。同样,如果我们在一个 item 迭代器被消耗之前推进外部迭代器,它需要“快进”通过底层迭代器,直到找到一个新组的开始。
清单 9-10 中的代码是一个单独的函数,它委托给我们的 get data 函数,并将其包装在适当的 groupby 逻辑中,而不是一个可以适应任何迭代器的通用函数。
async def get_data_by_deployment(
*args, **kwargs
) -> t.AsyncIterator[t.Tuple[UUID, t.AsyncIterator[DataPoint]]]:
"""Return an Async Iterator that contains two-item pairs.
These pairs are a string (deployment_id), and an async iterator that contains
the datapoints with that deployment_id.
Usage example:
async for deployment_id, datapoints in get_data_by_deployment():
print(deployment_id)
async for datapoint in datapoints:
print(datapoint)
print()
"""
# Get the data, using the arguments to this function as filters
data = get_data(*args, **kwargs)
# The two levels of iterator share the item variable, initialise it # with the first item from the iterator. Also set last_deployment_id
# to None, so the outer iterator knows to start a new group.
last_deployment_id: t.Optional[UUID] = None
try:
item = await data.__anext__()
except StopAsyncIteration:
# There were no items in the underlying query, return immediately
return
async def subiterator(group_id: UUID) -> t.AsyncIterator[DataPoint]:
"""Using a closure, create an iterator that yields the current
item, then yields all items from data while the deployment_id matches
group_id, leaving the first that doesn't match as item in the enclosing
scope."""
# item is from the enclosing scope
nonlocal item
while item.deployment_id == group_id:
# yield items from data while they match the group_id this iterator represents
yield item
try:
# Advance the underlying iterator
item = await data.__anext__()
except StopAsyncIteration:
# The underlying iterator came to an end, so end the subiterator too
return
while True:
while item.deployment_id == last_deployment_id:
# We are trying to advance the outer iterator while the
# underlying iterator is still part-way through a group.# Speed through the underlying until we hit an item where
# the deployment_id is different to the last one (or,
# is not None, in the case of the start of the iterator)
try:
item = await data.__anext__()
except StopAsyncIteration:
# We hit the end of the underlying iterator: end this # iterator too
return
last_deployment_id = item.deployment_id
# Instantiate a subiterator for this group
yield last_deployment_id, subiterator(last_deployment_id)
Listing 9-10An implementation of get_data_by_deployment that acts like an asynchronous groupby
这使用await
data.__anext__()
来推进底层数据迭代器,而不是异步 for 循环,以使迭代器在多个地方被使用的事实更加明显。
这个生成器协程的实现在本章的代码中。我鼓励您尝试添加打印语句和断点,以帮助理解控制流。这段代码比您需要编写的大多数 Python 代码都要复杂(我要提醒您不要将这种复杂程度引入到生产代码中;把它作为一个自包含的依赖项更好),但是如果你能理解它是如何工作的,你就能彻底掌握生成器函数、异步迭代器和闭包的细节。随着异步代码在生产代码中的使用越来越多,提供这种迭代器复杂操作的库肯定会出现。
附加过滤器
我们为sensor_name
和deployment_id
添加了get_data(...)
过滤器,但是选择显示的时间范围也很有用。我们可以用两个日期时间过滤器来实现这一点,这两个过滤器用于过滤collected_at
字段。清单 9-11 中显示了支持此功能的get_data(...)
的实现,但是因为get_data_by_deployment(...)
将所有参数原封不动地传递给get_data(...)
,所以我们不需要修改该函数来允许我们的分析中的日期窗口。
async def get_data(
sensor_name: t.Optional[str] = None,
deployment_id: t.Optional[UUID] = None,
collected_before: t.Optional[datetime.datetime] = None,
collected_after: t.Optional[datetime.datetime] = None,
) -> t.AsyncIterator[DataPoint]:
db_session = db_session_var.get()
loop = asyncio.get_running_loop()
query = db_session.query(datapoint_table)
if sensor_name:
query = query.filter(datapoint_table.c.sensor_name == sensor_name)
if deployment_id:
query = query.filter(datapoint_table.c.deployment_id == deployment_id)
if collected_before:
query = query.filter(datapoint_table.c.collected_at < collected_before)
if collected_after:
query = query.filter(datapoint_table.c.collected_at > collected_after)
query = query.order_by(
datapoint_table.c.deployment_id,
datapoint_table.c.sensor_name,
datapoint_table.c.collected_at,
)
rows = await loop.run_in_executor(None, query.all)
for row in rows:
yield DataPoint.from_sql_result(row)
Listing 9-11get_data method with sensor, deployment, and date filters
测试我们的查询功能
查询功能需要测试,就像其他任何功能一样。与我们到目前为止编写的大多数函数不同,查询函数带有大量可选参数,这些参数会显著改变返回数据的输出。虽然我们不需要为每个过滤器测试大范围的值(我们可以相信我们的数据库的查询支持工作正常),但我们需要测试每个选项是否按预期工作。
我们需要一些安装夹具来测试依赖于数据库的功能。虽然我们可以模拟数据库连接,但我不推荐这样做,因为数据库是非常复杂的软件,不太适合被模拟。
测试数据库应用最常见的方法是创建一个新的空数据库,并允许测试控制表和数据的创建。一些数据库软件,比如 SQLite,允许动态创建新的数据库,但是大多数都需要预先建立数据库。
假设我们有一个空的数据库,我们需要一个连接它的夹具,一个设置表的夹具,一个设置数据的夹具。连接夹具与with_database
上下文管理器、 5 非常相似,填充数据库的函数将包括我们可以使用db_session.execute(datapoint_table.insert().values(...))
插入的样本数据。
建立数据库表的设备是最困难的。最简单的方法是使用metadata.create_all(...)
,就像我们在引入数据库迁移的 alembic 之前所做的那样。这适用于大多数应用,因此通常是最佳选择。我们的应用包括一个数据库视图,它不是由 SQLAlchemy 管理的,而是由 Alembic 中的一个定制迁移管理的。因此,我们需要使用 Alembic 的升级功能来设置我们的数据库表。我们需要的相关夹具如清单 9-12 所示。
import datetime
from uuid import UUID
from apd.aggregation.database import datapoint_table
from alembic.config import Config
from alembic.script import ScriptDirectory
from alembic.runtime.environment import EnvironmentContext
import pytest
@pytest.fixture
def db_uri():
return "postgresql+psycopg2://apd@localhost/apd-test"
@pytest.fixture
def db_session(db_uri):
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
engine = create_engine(db_uri, echo=True)
sm = sessionmaker(engine)
Session = sm()
yield Session
Session.close()
@pytest.fixture
def migrated_db(db_uri, db_session):
config = Config()
config.set_main_option("script_location", "apd.aggregation:alembic")
config.set_main_option("sqlalchemy.url", db_uri)
script = ScriptDirectory.from_config(config)
def upgrade(rev, context):
return script._upgrade_revs(script.get_current_head(), rev)
def downgrade(rev, context):
return script._downgrade_revs(None, rev)
with EnvironmentContext(config, script, fn=upgrade):
script.run_env()
try:
yield
finally:
# Clear any pending work from the db_session connection
db_session.rollback()
with EnvironmentContext(config, script, fn=downgrade):
script.run_env()
@pytest.fixture
def populated_db(migrated_db, db_session):
datas = [
{
"id": 1,
"sensor_name": "Test",
"data": "1",
"collected_at": datetime.datetime(2020, 4, 1, 12, 0, 1),
"deployment_id": UUID("b4c68905-b1e4-4875-940e-69e5d27730fd"),
},
# Additional sample data omitted from listing for brevity's sake
]
for data in datas:
insert = datapoint_table.insert().values(**data)
db_session.execute(insert)
Listing 9-12Database setup fixtures
这为我们提供了一个环境,我们可以在其中编写测试来查询只包含已知值的数据库,因此我们可以编写有意义的断言。
参数化测试
Pytest 有一个特殊的功能,可以生成做一些非常相似的事情的多个测试:标记parameterize
。如果一个测试函数被标记为参数化的,那么它可以有不对应于 fixtures 的附加参数,以及这些参数的一系列值。测试函数将运行多次,每个不同的参数值函数运行一次。我们可以使用这个特性编写函数来测试我们函数的各种过滤方法,而不需要大量的重复,如清单 9-13 所示。
class TestGetData:
@pytest.fixture
def mut(self):
return get_data
@pytest.mark.asyncio
@pytest.mark.parametrize(
"filter,num_items_expected",
[
({}, 9),
({"sensor_name": "Test"}, 7),
({"deployment_id": UUID("b4c68905-b1e4-4875-940e-69e5d27730fd")}, 5),
({"collected_after": datetime.datetime(2020, 4, 1, 12, 2, 1),}, 3),
({"collected_before": datetime.datetime(2020, 4, 1, 12, 2, 1),}, 4),
(
{
"collected_after": datetime.datetime(2020, 4, 1, 12, 2, 1),
"collected_before": datetime.datetime(2020, 4, 1, 12, 3, 5),
},
2,
),
],
)
async def test_iterate_over_items(
self, mut, db_session, populated_db, filter, num_items_expected
):
db_session_var.set(db_session)
points = [dp async for dp in mut(**filter)]
assert len(points) == num_items_expected
Listing 9-13A parameterized get_data test to verify different filters
第一次运行这个测试时,它将filter={}, num_items_expected=9
作为参数。第二次运行有filter={"sensor_name": "Test"}, num_items_expected=7
,以此类推。这些测试功能中的每一个都将独立运行,并将被视为新的通过或未通过测试,视情况而定。
这将导致生成六个测试,名称类似于TestGetData.test_iterate_over_items[filter5-2]
。这个名称是基于参数的,复杂的参数值(如filter
)由它们的名称和列表中从零开始的索引来表示,简单的参数(如num_items_expected
)直接包含在内。大多数情况下,您不需要关心名称,但是识别测试失败的变体是非常有用的。
显示多个传感器
我们现在有三个函数可以帮助我们连接到数据库,并以合理的顺序和可选的过滤迭代DataPoint
对象。到目前为止,我们一直在使用matplotlib.pyplot.plot_dates(...)
函数将成对的传感器值和日期转换成一个图表。这是一个辅助函数,通过在全局名称空间中提供各种绘图函数,使生成绘图变得更加容易。在制作多个图表时,这不是推荐的方法。
我们希望能够遍历我们的每种传感器类型,并为每种类型生成一个图表。如果我们要使用 pyplot API,我们将被限制使用一个单独的绘图,最高的值使轴倾斜,使得最低的值无法读取。相反,我们希望为每一个生成一个独立的图,并将它们并排显示。为此,我们可以使用matplotlib.pyplot.figure(...)
和figure.add_subplot(...)
函数。子情节是一个对象,其行为大体上类似于matplotlib.pyplot
,但是代表了一个更大的情节网格中的一个单独的情节。例如,figure.add_subplot(3,2,4)
是三行两列网格图中的第四个图。
现在,我们的plot(...)
函数假设它正在处理的数据是一个数字,可以直接传递给 matplotlib 以显示在我们的图表上。不过,我们的许多传感器都有不同的数据格式,例如温度传感器,它有一个温度字典,单位被用作它的值属性。这些不同的值需要先转换成数字,然后才能绘制出来。
我们可以在apd.aggregation
中将我们的绘图函数重构为一个实用函数,以极大地简化我们的 Jupyter 笔记本,但我们需要确保它可以用于其他格式的传感器数据。每个图都需要为要绘制的传感器提供一些配置、绘制图的子图对象,以及从部署 id 到用于填充图的图例的面向用户的名称的映射。它还应该接受与get_data(...)
相同的过滤参数,允许用户通过日期或部署 id 来约束他们的图表。
我们将把这个配置数据作为一个数据类的实例传递,它还包含一个对“clean”函数的引用。这个 clean 函数负责将 DataPoint 实例转换成一对可以由 matplotlib 绘制的值。clean 函数必须将DataPoint
对象的 iterable 转换为 matplotlib 可以理解的(x, y)
对的 iterable。对于RelativeHumidity
和RAMAvailable
传感器,这是一个产生日期/浮点元组的简单问题,就像我们的代码到目前为止所做的那样。
async def clean_passthrough(
datapoints: t.AsyncIterator[DataPoint],
) -> t.AsyncIterator[t.Tuple[datetime.datetime, float]]:
async for datapoint in datapoints:
if datapoint.data is None:
continue
else:
yield datapoint.collected_at, datapoint.data
config 数据类还需要一些字符串参数,例如图表的标题、轴标签和 sensor_name,这些参数需要传递给get_data(...)
,以便找到该图表所需的数据。一旦定义了Config
类,我们就可以创建两个 config 对象来表示两个传感器,这两个传感器使用原始浮点数作为它们的值类型,并创建一个函数来返回所有注册的配置。
将 matplotlib 中的图形函数与我们新的配置系统结合起来,我们可以编写一个新的plot_sensor(...)
函数(清单 9-14 ),它可以使用 Jupyter 笔记本中的几行简单代码生成任意数量的图表。
@dataclasses.dataclass(frozen=True)
class Config:
title: str
sensor_name: str
clean: t.Callable[[t.AsyncIterator[DataPoint]], t.AsyncIterator[t.Tuple[datetime.datetime, float]]]
ylabel: str
configs = (
Config(
sensor_name="RAMAvailable",
clean=clean_passthrough,
title="RAM available",
ylabel="Bytes",
),
Config(
sensor_name="RelativeHumidity",
clean=clean_passthrough,
title="Relative humidity",
ylabel="Percent",
),
)
def get_known_configs() -> t.Dict[str, Config]:
return {config.title: config for config in configs}
async def plot_sensor(config: Config, plot: t.Any, location_names: t.Dict[UUID,str], **kwargs) -> t.Any:
locations = []
async for deployment, query_results in get_data_by_deployment(sensor_name=config.sensor_name, **kwargs):
points = dp async for dp in config['clean']
if not points:
continue
locations.append(deployment)
x, y = zip(*points)
plot.set_title(config['title'])
plot.set_ylabel(config['ylabel'])
plot.plot_date(x, y, "-", xdate=True)
plot.legend([location_names.get(l, l) for l in locations])
return plot
Listing 9-14New config objects and plot function that uses it
有了这些新函数,我们可以修改 Jupyter 笔记本单元格来调用plot_sensor(...)
函数,而不是在 Jupyter 中编写我们自己的绘图函数。由于这些助手函数,apd.aggregation 的最终用户需要编写的连接到数据库并呈现两个图表的代码(如清单 9-15 所示)大大缩短了。
import asyncio
from matplotlib import pyplot as plt
from apd.aggregation.query import with_database
from apd.aggregation.analysis import get_known_configs, plot_sensor
with with_database("postgresql+psycopg2://apd@localhost/apd") as session:
coros = []
figure = plt.figure(figsize = (20, 5), dpi=300)
configs = get_known_configs()
to_display = configs["Relative humidity"], configs["RAM available"]
for i, config in enumerate(to_display, start=1):
plot = figure.add_subplot(1, 2, i)
coros.append(plot_sensor(config, plot, {}))
await asyncio.gather(*coros)
display(figure)
Listing 9-15Jupyter cell to plot both Humidity and RAM Available, and their output
由于Temperature
和SolarCumulativeOutput
传感器以{'unit': 'degC', 'magnitude': 8.4}
的格式从pint
包中返回序列化的对象,我们不能将这些与我们现有的clean_passthrough()
函数一起使用;我们需要创造一个新的。最简单的是假设单位总是相同的,只提取量级线。这将会不正确地用不同的标度绘制任何温度,因为单位没有被校正。目前,我们所有的传感器都返回以摄氏度为单位的值,所以这不是一个严重的问题。
async def clean_magnitude(datapoints):
async for datapoint in datapoints:
if datapoint.data is None:
continue
yield datapoint.collected_at, datapoint.data["magnitude"]
如果我们使用这个新的 cleaner 函数来添加一个新的温度配置对象,我们会看到图 9-2 中的图表。从这些数据中我们可以清楚地看到,温度传感器并不完全可靠:我办公室的温度很少超过钢的熔点。
图 9-2
温度传感器输出有明显的误差,扭曲了数据
处理数据
我们采用的方法的一个优点是,我们可以对给定的数据执行相对任意的转换,从而允许我们丢弃我们认为不正确的数据点。在分析时丢弃数据通常比在收集时丢弃数据更好,因为检查数据点有效性的函数中的错误不会导致数据丢失,如果只是在分析时检查的话。我们总是可以在事后删除不正确的数据,但我们永远无法回忆起我们选择忽略的数据。
解决温度传感器问题的一种方法是让 clean 迭代器查看底层数据的移动窗口,而不是一次只查看一个DataPoint
。这样,它可以使用传感器值的邻居来丢弃差异太大的值。
collections.deque
类型对此很有用,因为它提供了一个具有可选最大大小的结构,所以我们可以将找到的每个温度添加到 deque 中,但是当读取它时,我们只能看到最后添加的n
条目。deque 可以从左边缘或右边缘添加或移除项目,因此在将其用作受限窗口时,从同一端添加和弹出项目的一致性非常重要。
我们可以从过滤掉 DHT22 传感器、【6】支持范围之外的任何值开始,以去除最不正确的数据。这消除了许多(但不是全部)不正确的读数。过滤出单项峰值的一个简单方法是有一个三项窗口,产生中间项,除非它与两边的平均温度相差太大,如清单 9-16 所示。我们不想消除所有合理的波动,所以我们对“没有太大差异”的定义必须考虑到,诸如 21c、22c、21c 的读数运行是合理的,同时排除诸如 20c、60c、23c 的运行。
async def clean_temperature_fluctuations(
datapoints: t.AsyncIterator[DataPoint],
) -> t.AsyncIterator[t.Tuple[datetime.datetime, float]]:
allowed_jitter = 2.5
allowed_range = (-40, 80)
window_datapoints: t.Deque[DataPoint] = collections.deque(maxlen=3)
def datapoint_ok(datapoint: DataPoint) -> bool:
"""Return False if this data point does not contain a valid temperature"""
if datapoint.data is None:
return False
elif datapoint.data["unit"] != "degC":
# This point is in a different temperature system. While it # could be converted
# this cleaner is not yet doing that.
return False
elif not allowed_range[0] < datapoint.data["magnitude"] < allowed_range[1]:
return False
return True
async for datapoint in datapoints:
if not datapoint_ok(datapoint):
# If the datapoint is invalid then skip directly to the next item
continue
window_datapoints.append(datapoint)
if len(three_temperatures) == 3:
# Find the temperatures of the datapoints in the window, then # average
# the first and last and compare that to the middle point.
window_temperatures = [dp.data["magnitude"] for dp in window_datapoints]
avg_first_last = (window_temperatures[0] + window_temperatures[2]) / 2
diff_middle_avg = abs(window_temperatures[1] - avg_first_last)
if diff_middle_avg > allowed_jitter:
pass
else:
yield window_datapoints[1].collected_at, window_temperatures[1]
else:
# The first two items in the iterator can't be compared to both # neighbors
# so they should be yielded
yield datapoint.collected_at, datapoint.data["magnitude"]
# When the iterator ends the final item is not yet in the middle
# of the window, so the last item must be explicitly yielded
if datapoint_ok(datapoint):
yield datapoint.collected_at, datapoint.data["magnitude"]
Listing 9-16An example implementation of a cleaner function for temperature
如图 9-3 所示,这种清洁功能可以产生更加平滑的温度趋势。清洗器过滤掉任何找不到温度的数据点以及任何严重的错误。它保留了温度趋势的细节;由于该窗口包含最后三个记录的数据点(甚至那些没有从数据集中排除的数据点),只要温度的突然变化持续至少两个连续读数,它就会开始反映在输出数据中。
图 9-3
使用适当的清洁剂时,相同数据的结果
Exercise 9-1: Add a Cleaner For Solarcumulativeoutput
SolarCumulativeOutput
传感器返回瓦特小时数,其序列化方式与温度传感器相同。如果我们绘制这个图表,我们会看到一条不规则移动的上升趋势线。更有用的是看到某一时刻的发电量,而不是该时刻之前的总发电量。
为了实现这一点,我们需要将瓦特小时转换为瓦特,这意味着将瓦特小时数除以数据点之间的时间量。
编写一个clean_watthours_to_watts(...)
迭代器协程,跟踪最后的时间和瓦特小时读数,找出差异,然后返回瓦特除以经过的时间。
例如,以下两个日期和值对将在下午 1 点产生一个值为 5.0 的输出条目。
[
(datetime.datetime(2020, 4, 1, 12, 0, 0), {"magnitude": 1.0, "unit": "watt_hour"}),
(datetime.datetime(2020, 4, 1, 13, 0, 0), {"magnitude": 6.0, "unit": "watt_hour"})
]
本章附带的代码包含本练习的一个工作环境,包括一个测试设置和一系列针对该功能的单元测试,但没有实现。本章的最终代码中还有一个 cleaner 的实现。
有了这些用于太阳能和温度的清洁器和配置条目,我们可以绘制一个 2x2 的图表网格。由于图表现在显示了所需的数据,现在是通过添加部署名称的值来提高可读性的好时机,这些值作为最终参数传递给清单 9-17 中的plot_sensor(...)
。
import asyncio
from uuid import UUID
from matplotlib import pyplot as plt
from apd.aggregation.query import with_database
from apd.aggregation.analysis import get_known_configs, plot_sensor
location_names = {
UUID('53998a51-60de-48ae-b71a-5c37cd1455f2'): "Loft",
UUID('1bc63cda-e223-48bc-93c2-c1f651779d69'): "Living Room",
UUID('ea0683de-6772-4678-bfe7-6014f54ffc8e'): "Office",
UUID('5aaa901a-7564-41fb-8eba-50cdd6fe9f80'): "Outside",
}
with with_database("postgresql+psycopg2://apd@localhost/apd") as session:
coros = []
figure = plt.figure(figsize = (20, 10), dpi=300)
configs = get_known_configs().values()
for i, config in enumerate(configs, start=1):
plot = figure.add_subplot(2, 2, i)
coros.append(plot_sensor(config, plot, location_names))
await asyncio.gather(*coros)
display(figure)
Listing 9-17Final Jupyter cell to display 2x2 grid of charts
与 Jupyter 小工具的交互性
到目前为止,我们生成图表的代码对最终用户没有交互性。我们目前显示所有记录的数据点,但是如果能够过滤,只显示一个时间段,而不需要修改代码来生成图表,将会非常方便。
为此,我们使用setup.cfg
的extras_require
功能在ipywidgets
上添加一个可选的依赖项,并使用pipenv install -e .[jupyter]
在我们的环境中重新安装apd.aggregation
包。
您可能还需要运行以下命令,以确保系统范围的 Jupyter 安装启用了对小部件的支持功能:
> pip install --user widgetsnbextension
> jupyter nbextension enable --py widgetsnbextension
安装了这个之后,我们可以请求 Jupyter 为每个参数创建交互式小部件,并使用用户选择的值调用函数。交互性允许查看笔记本的人选择任意输入值,而不需要修改单元格的代码,甚至不需要理解代码。
图 9-4 显示了一个将两个整数相加的函数示例,该函数已经连接到 Jupyter 的交互支持。在这种情况下,两个整数参数被赋予默认值 100,并呈现为滑块。用户可以操作这些滑块,函数的结果会自动重新计算。
图 9-4
加法函数的交互式视图
多重嵌套的同步和异步代码
我们不能将协程传递给interactive(...)
函数,因为它被定义为一个标准的同步函数。它本身是一个同步函数,所以它甚至不可能await
协程调用的结果。尽管 IPython 和 Jupyter 允许在通常不允许的地方使用await
结构,但这是通过将单元封装在协程 7 中并将其作为任务调度来实现的;真正将同步和异步代码结合在一起的并不是深奥的魔法,而是一种为了方便而进行的黑客攻击。
我们的绘图代码需要等待plot_sensor(...)
协程,所以 Jupyter 必须将单元格包装到协程中。协程只能由协程调用或直接在事件循环的run(...)
函数上调用,因此异步代码通常会增长到整个应用都是异步的程度。拥有一组全同步或全异步的函数比混合这两种方法要容易得多。
我们在这里不能这样做,因为我们需要向interactive(...)
提供一个函数,我们无法控制这个函数的实现。我们解决这个问题的方法是,我们必须将协程转换成一个新的同步方法。我们不希望仅仅为了适应interactive(...)
函数而将所有代码重写为同步样式,所以用包装器函数来弥补这个差距是更好的选择。
协程需要访问一个事件循环,它可以使用该事件循环来调度任务,并负责调度任务。我们现有的事件循环不会这样做,因为它正忙于执行等待interactive(...)
返回的协程。如果你还记得,在 asyncio 中是await
关键字实现了协作多任务,所以我们的代码只有在遇到await
表达式时才会在不同的任务之间切换。
如果我们正在运行一个协程,我们可以await
另一个协程或任务,这允许事件循环执行其他代码。直到被等待的函数完成执行,执行才会返回到我们的代码,但是其他的协程可以同时运行。我们可以从异步上下文中调用类似interactive(...)
的同步代码,但是这些代码会引入阻塞。因为这种阻塞不是对一个await
语句的阻塞,所以在此期间执行不能传递给另一个协程。从异步函数调用任何同步函数都相当于保证一个代码块不包含await
语句,这就保证了不会有其他协程的代码运行。
到目前为止,我们已经使用了asyncio.run(...)
函数从同步代码中启动一个协程并阻塞等待它的结果,但是我们已经在对asyncio.run(main())
的调用中,所以我们不能再这样做了。 8 由于interactive(...)
调用在没有await
表达式的情况下被阻塞,我们的包装器将运行在一个保证协程代码不能运行的环境中。尽管我们用来将异步协程转换为同步函数的包装函数必须安排协程的执行,但它不能依赖现有的事件循环来完成这项工作。
为了明确这一点,想象一个以两个函数作为参数的函数,如清单 9-18 所示。这两个函数都返回一个整数。这个函数调用作为参数传递的两个函数,将结果相加,然后返回这些整数的和。如果所有涉及的功能都是同步的,就没有问题。
import typing as t
def add_number_from_callback(a: t.Callable[[], int], b: t.Callable[[], int]) -> int:
return a() + b()
def constant() -> int:
return 5
print(add_number_from_callback(constant, constant))
Listing 9-18Example of calling only synchronous functions from a synchronous context
我们甚至可以从异步上下文中调用这个add_number_from_callback(...)
函数并得到正确的结果,但要注意的是add_number_from_callback(...)
会阻塞整个过程,这可能会抵消异步代码的好处。
async def main() -> None:
print(add_number_from_callback(constant, constant))
asyncio.run(main())
我们特殊的调用是低风险的,因为我们知道没有 IO 请求可能会阻塞很长时间。但是,我们可能希望添加一个新函数,从 HTTP 请求中返回一个数字。如果我们已经有了一个协同例程来获取 HTTP 请求的结果,我们可能希望使用它,而不是将它重新实现为一个同步函数。获取数字(在本例中是从 random.org 随机数生成器服务获取)的协程示例如下:
import aiohttp
async def async_get_number_from_HTTP_request() -> int:
uri = "https://www.random.org/integers/?num=1&min=1&max=100&col=1" "&base=10&format=plain"
async with aiohttp.ClientSession() as http:
response = await http.get(uri)
return int(await response.text())
由于这是一个协程,我们不能将其直接传递给add_number_from_callback(...)
函数。如果我们尝试,我们会看到 Python 错误TypeError: unsupported operand type(s) for +: 'int' and 'coroutine'
。 9
您可以为async_get_number_from_HTTP_request
编写一个包装函数来创建一个我们可以等待的新任务,但是这将把协程提交给现有的事件循环,我们已经决定这不是一个可行的解决方案。我们无法等待这个任务,因为在同步函数中使用await
是无效的,以嵌套方式调用asyncio.run(...)
也是无效的。等待这种情况的唯一方式是循环什么都不做,直到任务完成,但是这个循环阻止了事件循环调度任务,导致了矛盾。
def get_number_from_HTTP_request() -> int:
task = asyncio.create_task(async_get_number_from_HTTP_request())
while not task.done():
pass
return task.result()
main()
任务不断地在task.done()
检查中循环,从不命中await
语句,因此永远不会让位于async_get_number_from_HTTP_request()
任务。该函数会导致死锁。
Tip
也可以用任何不包含显式await
语句或隐式语句(如async for
和async with
)的长期运行循环来创建阻塞异步代码。
您不需要像我们在这里所做的那样,编写一个循环来检查另一个协程的数据。你应该await
协程而不是循环。如果您确实需要一个内部没有等待的循环,您可以通过等待一个什么也不做的函数(如await asyncio.sleep(0)
)来明确地给事件循环一个切换到其他任务的机会,只要您是在一个协程中循环,而不是在一个协程调用的同步函数中循环。
我们不能将整个调用堆栈转换成异步习惯用法,所以解决这个问题的唯一方法是启动第二个事件循环,允许两个任务并行运行。我们已经阻塞了当前的事件循环,但是我们可以启动第二个事件循环来执行异步 HTTP 代码。
这种方法使得从同步上下文中调用异步代码成为可能,但是在主事件循环中调度的所有任务仍然被阻塞,等待 HTTP 响应。这只解决了混合同步和异步代码时的死锁问题;性能损失仍然存在。您应该尽可能避免混合同步和异步代码。由此产生的代码难以理解,可能会引入死锁,并抵消 asyncio 的性能优势。
清单 9-19 给出了一个助手函数,它采用一个协程并在一个新线程中执行它,而不涉及当前运行的事件循环。这还包括一个协程,它利用这个包装器传递 HTTP 协程,就像它是一个同步函数一样。
def wrap_coroutine(f):
@functools.wraps(f)
def run_in_thread(*args, **kwargs):
loop = asyncio.new_event_loop()
wrapped = f(*args, **kwargs)
with ThreadPoolExecutor(max_workers=1) as pool:
task = pool.submit(loop.run_until_complete, wrapped)
return task.result()
return run_in_thread
async def main() -> None:
print(
add_number_from_callback(
constant, wrap_coroutine(async_get_number_from_HTTP_request)
)
)
Listing 9-19Wrapper function to start a second event loop and delegate new async tasks there
我们可以使用同样的方法来允许我们的plot_sensor(...)
协程在interactive(...)
函数调用中使用,如清单 9-20 所示。
import asyncio
from uuid import UUID
import ipywidgets as widgets
from matplotlib import pyplot as plt
from apd.aggregation.query import with_database
from apd.aggregation.analysis import (get_known_configs, plot_sensor, wrap_coroutine)
@wrap_coroutine
async def plot(*args, **kwargs):
location_names = {
UUID('53998a51-60de-48ae-b71a-5c37cd1455f2'): "Loft",
UUID('1bc63cda-e223-48bc-93c2-c1f651779d69'): "Living Room",
UUID('ea0683de-6772-4678-bfe7-6014f54ffc8e'): "Office",
UUID('5aaa901a-7564-41fb-8eba-50cdd6fe9f80'): "Outside",
}
with with_database("postgresql+psycopg2://apd@localhost/apd") as session:
coros = []
figure = plt.figure(figsize = (20, 10), dpi=300)
configs = get_known_configs().values()
for i, config in enumerate(configs, start=1):
plot = figure.add_subplot(2, 2, i)
coros.append(plot_sensor(config, plot, location_names, *args, **kwargs))
await asyncio.gather(*coros)
return figure
start = widgets.DatePicker(
description='Start date',
)
end = widgets.DatePicker(
description='End date',
)
out = widgets.interactive(plot, collected_after=start, collected_before=end)
display(out)
Listing 9-20Interactive chart filtering example, with output shown
整理
我们现在在 Jupyter 细胞中有许多复杂的逻辑。我们应该把它移到一些更通用的实用函数中,这样终端用户就不需要处理如何绘制图表的细节。我们不希望用户必须处理将协程转换成包装函数以传递给交互系统的细节,因此我们可以提供一个助手函数供他们使用,如清单 9-21 所示。
async def plot_multiple_charts(*args: t.Any, **kwargs: t.Any) -> Figure:
# These parameters are pulled from kwargs to avoid confusing function
# introspection code in IPython widgets
location_names = kwargs.pop("location_names", None)
configs = kwargs.pop("configs", None)
dimensions = kwargs.pop("dimensions", None)
db_uri = kwargs.pop("db_uri", "postgresql+psycopg2://apd@localhost/apd")
with with_database(db_uri):
coros = []
if configs is None:
# If no configs are supplied, use all known configs
configs = get_known_configs().values()
if dimensions is None:
# If no dimensions are supplied, get the square root of the # number
# of configs and round it to find a number of columns. This will
# keep the arrangement approximately square. Find rows by
# multiplying out rows.
total_configs = len(configs)
columns = round(math.sqrt(total_configs))
rows = math.ceil(total_configs / columns)
figure = plt.figure(figsize=(10 * columns, 5 * rows), dpi=300)
for i, config in enumerate(configs, start=1):
plot = figure.add_subplot(columns, rows, i)
coros.append(plot_sensor(config, plot, location_names, *args, **kwargs))
await asyncio.gather(*coros)
return figure
def interactable_plot_multiple_charts(
*args: t.Any, **kwargs: t.Any
) -> t.Callable[..., Figure]:
with_config = functools.partial(plot_multiple_charts, *args, **kwargs)
return wrap_coroutine(with_config)
Listing 9-21Genericized versions of the plot functions
这给我们留下了 Jupyter 代码,它实例化小部件和位置名称,然后调用interactable_plot_multiple_charts(...)
来生成传递给interactive(...)
函数的函数。由此产生的 Jupyter 单元相当于前面的实现,但要短得多,如下所示:
import ipywidgets as widgets
from apd.aggregation.analysis import interactable_plot_multiple_charts
plot = interactable_plot_multiple_charts(location_names=location_names)
out = widgets.interact(plot, collected_after=start, collected_before=end)
display(out)
持久端点
我们可以做的下一个逻辑清理是将端点的配置移动到一个新的数据库表中。这将允许我们自动生成location_names
变量,确保每个图表上使用的颜色在调用中保持一致,还允许我们更新所有传感器端点,而不必每次都传递它们的 URL。
为此,我们将创建一个新的数据库表和数据类来表示 apd.sensors 的部署。我们还需要命令行实用程序来添加和编辑部署元数据、实用程序函数来获取数据,并对所有这些进行测试。
Exercise 9-2: Implement Stored Deployments
在数据库中存储部署所涉及的更改需要创建新的表、新的控制台脚本、迁移和一些测试工作。
根据您认为有用的内容,实现以下任意或所有功能:
-
包含 id、名称、URI 和 API 键的部署对象和表
-
用于添加、编辑和列出部署的命令行脚本
-
命令行脚本的测试
-
使 collect_sensor_data 的服务器和 api_key 参数可选,如果省略,则使用存储的值
-
通过 ID 获取部署记录的助手函数
-
部署表中用于绘制数据的颜色的附加字段
-
修改绘图函数以直接使用其数据库记录中的展开名称和线条颜色
所有这些都包含在本章附带的同一个实现中。
绘制地图和地理数据
在这一章中,我们一直关注 xy 值与时间的关系图,因为它代表了我们一直在检索的测试数据。有时我们需要根据其他坐标轴绘制数据。其中最常见的是纬度对经度,因此该图类似于一张地图。
如果我们从数据集中提取纬度和经度项(比方说,一个将坐标映射到英国各地温度记录的字典),我们可以将这些作为参数传递给plot(...)
来查看它们的可视化,如清单 9-22 所示。
import matplotlib.pyplot as plt
fig, ax = plt.subplots()
lats = [ll[0] for ll in datapoints.keys()]
lons = [ll[1] for ll in datapoints.keys()]
ax.plot(lons, lats, "o")
plt.show()
Listing 9-22Plotting lat/lons using matplotlib, and the resulting chart
数据的形状只是非常近似地像大不列颠的轮廓,如图 9-5 所示。大多数人看到这个情节时都不会这样认为。
图 9-5
包括英格兰、威尔士和苏格兰的大不列颠岛的轮廓
失真是因为我们是根据等矩形地图投影绘制的,其中纬度和经度是等间距网格,没有考虑地球的形状。没有一个正确的地图投影;这在很大程度上取决于地图的预期用途。
我们需要这张地图让大多数人看起来很熟悉,不管他们生活在哪个国家,他们都会非常熟悉这个国家的轮廓。我们希望看到它的人看到的是数据,而不是不寻常的投影。最常用的投影是墨卡托投影,OpenStreetMap (OSM)项目为其提供了多种编程语言的实现,包括 Python。 10 实现投影的merc_x(...)
和merc_y(...)
函数不会包含在清单中,因为它们是相当复杂的数学函数。
Tip
当绘制显示数百平方公里区域的地图时,使用投影功能变得越来越重要,但对于小比例地图,可以使用ax.set_aspect(...)
功能提供更熟悉的视图。改变纵横比将失真最小的点从赤道移动到另一个纬度;它不能校正失真。例如,ax.set_aspect(1.7)
会将失真最小的点移到纬度 54 度,因为1.7
等于1 / cos(54)
。
有了投影功能,我们可以重新运行绘图功能,看到这些点与我们期望的轮廓更加匹配,如图 9-6 所示。在这种情况下,轴上的标签不再显示坐标;它们显示无意义的数字。我们现在应该忽略这些标签。
图 9-6
使用来自 OSM 的 merc_x 和 merc_y 投影的地图
新的地块类型
这只向我们显示了每个数据点的位置,而不是与之相关的值。到目前为止,我们使用的绘图函数都绘制两个值,x 和 y 坐标。虽然我们可以用温度来标记绘图点,或者用刻度来标记颜色,但最终的图表并不容易阅读。相反,matplotlib 中有一些其他的绘图类型可以帮助我们:特别是tricontourf(...)
。tricontour 绘图函数系列采用(x, y, value)
的三维输入,并在它们之间进行插值,以创建一个带有代表一系列值的颜色区域的绘图。
当 tricontour 函数绘制颜色区域时,我们也应该绘制进行测量的点,尽管不太突出(清单 9-23 )。这与在图表上绘制多个数据集的方式相同;我们可以根据需要多次调用各种绘图函数来显示所有数据;它们不必是相同类型的图,只要轴是兼容的。
fig, ax = plt.subplots()
lats = [ll[0] for ll in datapoints.keys()]
lons = [ll[1] for ll in datapoints.keys()]
temperatures = tuple(datapoints.values())
x = tuple(map(merc_x, lons))
y = tuple(map(merc_y, lats))
ax.tricontourf(x, y, temperatures)
ax.plot(x, y, 'wo', ms=3)
ax.set_aspect(1.0)
plt.show()
Listing 9-23Color contours and scatter on the same plot
一旦我们知道我们在看什么,这是可以理解的,但我们可以通过在地图上标出大不列颠岛的海岸线来进一步改进它。给定代表英国海岸线的坐标列表, 11 我们可以最后一次调用 plot 函数,这次指定我们要画一条线而不是点。我们绘图的最终版本(图 9-7 )更容易阅读,特别是如果我们能够通过调用plt.colorbar(tcf)
来绘制图例,其中tcf
是ax.tricontourf(...)
函数调用的结果。
图 9-7
典型冬日英国周围的气温图
Tip
Python 和 Matplotlib 提供了许多 GIS 库,使复杂的地图变得更加简单。如果你打算画很多地图,我建议你看看 Fiona 和 Shapely,它们可以轻松地操作点和多边形。我向所有使用 Python 处理地理信息的人强烈推荐这些库;他们确实非常强大。
matplotlib 的底图工具包提供了非常灵活的地图绘制工具,但是维护人员已经决定不像标准 Python 包那样分发它,所以我无法推荐它作为地图绘制的通用解决方案。
apd.aggregation 中支持地图类型图表
我们需要对我们的配置对象进行一些更改来支持这些地图,因为它们的行为与我们到目前为止制作的所有其他图不同。之前,我们已经迭代了部署,并为每个部署绘制了一个图,代表一个传感器。要绘制地图,我们需要结合两个值(坐标和温度)并绘制一个代表所有部署的图。我们的个人部署可能会四处移动,并提供一个坐标传感器来记录他们在给定时间的位置。单独的自定义清理函数不足以组合多个数据点的值。
数据类中的向后兼容性
我们的Config
对象包含一个sensor_name
参数,它过滤作为绘图过程一部分的get_data_by_deployment(...)
函数调用的输出。我们需要覆盖系统的这一部分;我们不再希望向get_data_by_deployment(...)
函数传递单个参数;我们希望能够用自定义过滤来替换整个呼叫。
sensor_name=
参数已成为可选参数,类型已更改为InitVar
。我们还添加了一个新的 get_data 参数,这是一个可选的可调用参数,形状与get_data_by_deployment(...)
相同。InitVars 是数据类的另一个有用的特性,它允许指定参数,这些参数没有被存储,但是在一个名为__post_init__(...)
的创建后钩子中是可用的。在我们的例子中,如清单 9-24 所示,我们可以定义这样一个钩子来基于sensor_name=
设置新的get_data=
变量,保持与只传递一个sensor_name=
的实现的向后兼容性。
@dataclasses.dataclass
class Config:
title: str
clean: t.Callable[[t.AsyncIterator[DataPoint]], t.AsyncIterator[t.Tuple[datetime.datetime, float]]]
get_data: t.Optional[
t.Callable[..., t.AsyncIterator[t.Tuple[UUID, t.AsyncIterator[DataPoint]]]]
] = None
ylabel: str
sensor_name: dataclasses.InitVar[str] = None
def __post_init__(self, sensor_name=None):
if self.get_data is None:
if sensor_name is None:
raise ValueError("You must specify either get_data or sensor_name")
self.get_data = get_one_sensor_by_deployment(sensor_name)
def get_one_sensor_by_deployment(sensor_name):
return functools.partial(get_data_by_deployment, sensor_name=sensor_name)
Listing 9-24Data class with get_data parameter and backward compatibility hook
自动调用__post_init__(...)
函数,向其传递任何InitVar
属性。当我们在__post_init__
方法中设置get_data
时,我们需要确保数据类没有被冻结,因为这算作修改。
这一改变允许我们改变传递给clean(...)
函数的数据,但是该函数仍然期望返回一个传递给plot_date(...)
函数的时间和浮点元组。我们需要改变clean(...)
函数的形状。
我们将不再仅仅使用plot_date(...)
来表达我们的观点;某些类型的图表需要等高线和点,因此我们还必须添加另一个自定义点来选择数据的绘制方式。Config
类的新draw
属性提供了这个功能。
为了支持这些新的函数调用签名,我们需要使Config
成为一个泛型类,如清单 9-25 所示。这使得指定配置对象的基础数据成为可能(或者让类型系统从上下文中推断出它)。现有的数据类型是类型Config[datetime.datetime, float]
,但是我们的地图Config
将是Config[t.Tuple[float, float], float]
。也就是说,一些配置根据日期绘制一个浮点数,另一些配置根据一对浮点数绘制一个浮点数。
plot_key = t.TypeVar("plot_key")
plot_value = t.TypeVar("plot_value")
@dataclasses.dataclass
class Config(t.Generic[plot_key, plot_value]):
title: str
clean: t.Callable[
[t.AsyncIterator[DataPoint]], t.AsyncIterator[t.Tuple[plot_key, plot_value]]
]
draw: t.Optional[
t.Callable[
[t.Any, t.Iterable[plot_key], t.Iterable[plot_value], t.Optional[str]], None
]
] = None
get_data: t.Optional[
t.Callable[..., t.AsyncIterator[t.Tuple[UUID, t.AsyncIterator[DataPoint]]]]
] = None
ylabel: t.Optional[str] = None
sensor_name: dataclasses.InitVar[str] = None
def __post_init__(self, sensor_name=None):
if self.draw is None:
self.draw = draw_date
if self.get_data is None:
if sensor_name is None:
raise ValueError("You must specify either get_data or sensor_name")
self.get_data = get_one_sensor_by_deployment(sensor_name)
Listing 9-25A generic Config type
现在,Config
类中有许多复杂的类型信息。不过,这确实有好处:下面的代码引发了一个键入错误:
Config(
sensor_name="Temperature",
clean=clean_temperature_fluctuations,
title="Ambient temperature",
ylabel="Degrees C",
draw=draw_map,
)
当我们阅读代码时,它也给了我们信心;我们知道函数的参数和返回类型是匹配的。因为这段代码涉及到将数据结构转换成元组(等)迭代器的大量操作。很容易弄不清楚到底需要什么。这是输入提示的完美用例。
我们希望用户使用自定义绘制和清理方法来创建自定义配置对象。拥有可靠的打字信息可以让他们更快地发现细微的错误。
我们需要处理现有的两种绘图类型的config.get_data(...)
和config.draw(...)
函数是我们已经在本章中深入研究过的代码的重构,但是对于那些对细节感兴趣的人来说,它们可以在本章附带的代码中查看。
使用新配置绘制自定义地图
对Config
的更改允许我们定义基于地图的配置,但我们当前的数据不包括任何可以绘制为地图的数据,因为我们的部署都不包括位置传感器。我们可以使用新的config.get_data(...)
选项来生成一些静态数据,而不是真实的聚合数据来演示功能。我们还可以通过扩展draw_map(...)
函数来添加自定义海岸线(清单 9-26 )。
def get_literal_data():
# Get manually entered temperature data, as our particular deployment
# does not contain data of this shape
raw_data = {...}
now = datetime.datetime.now()
async def points():
for (coord, temp) in raw_data.items():
deployment_id = uuid.uuid4()
yield DataPoint(sensor_name="Location", deployment_id=deployment_id,
collected_at=now, data=coord)
yield DataPoint(sensor_name="Temperature", deployment_id=deployment_id,
collected_at=now, data=temp)
async def deployments(*args, **kwargs):
yield None, points()
return deployments
def draw_map_with_gb(plot, x, y, colour):
# Draw the map and add an explicit coastline
gb_boundary = [...]
draw_map(plot, x, y, colour)
plot.plot(
[merc_x(coord[0]) for coord in gb_boundary],
[merc_y(coord[1]) for coord in gb_boundary],
"k-",
)
country = Config(
get_data=get_literal_data(),
clean=get_map_cleaner_for("Temperature"),
title="Country wide temperature",
ylabel="",
draw=draw_map_with_gb,
)
out = widgets.interactive(interactable_plot_multiple_charts(configs=configs + (country, )), collected_after=start, collected_before=end)
Listing 9-26Jupyter function to draw a custom map chart along with the registered charts
Exercise 9-3: Add a Bar Chart For Cumulative Solar Power
我们为太阳能发电数据编写了一个清洁器,将它转换为瞬时功率,而不是累积功率。这使得随着时间的推移发电变得更加明显,但也使得理解每天的发电量变得更加困难。
编写一个新的 cleaner,返回每天的累积电量,并编写一个新的 draw 函数,以条形图的形式显示这些电量。
和往常一样,本章附带的代码包括一个起点和一个完整的示例版本。
摘要
在这一章中,我们回到了 Jupyter,它的目的是人们最熟悉的,而不是纯粹作为一个原型工具。我们在这里也使用了 Matplotlib,很多 Jupyter 的用户可能已经遇到过了。这两者共同构成了交流数据分析结果的强大工具。
我们已经编写了许多帮助函数,让人们可以轻松地在 Jupyter 中构建自定义界面来查看我们正在聚合的数据。这允许我们定义一个面向公众的 API,同时允许我们非常灵活地改变事情的实现方式。一个好的面向最终用户的 API 对于留住用户至关重要,所以值得花时间去做。
本章附带代码的最终版本包含了我们构建的所有函数,其中许多函数都包含很长的样本数据块。其中一些太长了,无法在出版物中包含,所以我建议您看看代码示例,并尝试一下。
最后,我们看了一些我们已经使用过的技术的更高级的用法,包括当缺省参数不够时使用数据类的__post_init__(...)
钩子来保持向后兼容性,以及同步和异步代码的更复杂的组合。
额外资源
以下链接提供了本章所涵盖主题的其他背景信息:
-
有关 matplotlib 图表上可用的格式选项的详细信息以及到其他图表类型的链接可在 matplotlib 文档中找到,位于
https://matplotlib.org/3.1.1/api/_as_gen/matplotlib.pyplot.plot.html#matplotlib.pyplot.plot
。 -
管理创建独立 postgresql 实例的测试助手库是 testing.postgresql,可从
https://github.com/tk0miya/testing.postgresql
获得。 -
OpenStreetMap 关于墨卡托投影的页面,包括不同实现的细节,是
https://wiki.openstreetmap.org/wiki/Mercator
。 -
Fiona 库,用于解析 Python 中的地理信息文件,记录在
https://fiona.readthedocs.io/en/latest/README.html
。 -
Shapely 库,用于在 Python 中操作复杂的 GIS 对象,可在
https://shapely.readthedocs.io/en/latest/manual.html
获得。我特别推荐这款;它在很多场合对我都很有用。
使用sys.getsizeof(...)
计算尺寸。这不包括对象上属性的大小,简单对象可以用sys.getsizeof(obj.__dict__)
找到。
2
使用timeit.timeit(...)
进行估算,如下所示:
setup = """
import datetime
import uuid
from sqlalchemy.util._collections import lightweight_named_tuple
result = lightweight_named_tuple("result", ["id", "collected_at", "sensor_name", "deployment_id", "data",])
data = (1, datetime.datetime.now(), "Example", uuid.uuid4(), None)
"""
timeit.timeit("result(data)", setup)
3
“o”样式指定一个圆形标记,没有线条。该字符串可以包含标记类型、线条样式和颜色。*r 将绘制红星,-是默认颜色的线条,没有标记,s - m 是由虚线连接的洋红色方块,等等。本章中的附加资源列表包含完整规范的链接。
4
翻转可重复项的可重复项的拆分方式。我发现最容易把这想象成旋转一个电子表格。如果您的输入 iterables 是["Matt", "Leeds"], ["Jesse", "Seattle"]
和"Nejc", "Ljubljana"
,您可以想象这相当于一个电子表格,其中姓名在 A 列,城市在 b 列。在这种情况下,Matt 是第 1 行,Jesse 是第 2 行,Nejc 是第 3 行。tuple(zip(*names_and_cities))
按顺序读出列,则为(('Matt', 'Jesse', 'Nejc'), ('Leeds', 'Seattle', 'Ljubljana'))
。
[5
我建议不要添加 commit()调用,因为这将允许在两次测试之间回滚对数据库的更改。
6
温度传感器旨在测量环境温度。如果我们编写一个新的传感器类型来收集不同类型的温度数据,我们可能需要重新考虑这个过滤器。
7
具体来说,IPython 试图将单元编译成字节码,并检查语法错误。如果有语法错误,它会将代码包装在一个协程中,然后重试。
8
asyncio.run(...)
不是可重入:调用不能嵌套。
9
Mypy would word the error as
error: Argument 2 to "add_number_from_callback" has incompatible type "Callable[[], Coroutine[Any, Any, int]]"; expected "Callable[[], int]"
10
wiki。openstreetmap。org/wiki/Mercator # Python _ implementation
11
用于提取这些内容的代码如下。数据来自 www。自然地球数据。com 数据集。
import fiona
path = "ne_10m_admin_0_countries.shp"
shape = fiona.open(path)
countries = tuple(shape)
UK = [country for country in countries if country['properties']['ADMIN'] == "United Kingdom"][0]
coastlines = UK['geometry']['coordinates']
by_complexity = sorted(coastlines, key=lambda coords: len(coords[0]))
gb_boundary = by_complexity[-1][0]
Jupyter 笔记本中省略了这一点,以减少使用它所需的依赖性。在实践中,将使用这个函数而不是文字元组。
*