本文的主体内容大部分来自对 PEP 492 原文的翻译,剩余部分是本人对原文的理解,在整理过程中我没有刻意地区分二者,这两部分被糅杂在一起形成了本文。因此请不要带着「本文的内容是百分百正确」的想法阅读。如果文中的某些内容让你产生疑惑,你可以给我留言与我讨论或者对比 PEP 492 的原文加以确认。
注:PEP 492 创建于 2015-04-09,Python 3.5
注:文中的「当前版本」指的是本提案生效之前的版本
注:本文过长,虽然已经自我校对过一次,但还是难免存在错别字或语句不通顺的地方,如果您发现了问题欢迎留言给我
网络请求爆发性地增长引发了对低延时、可拓展代码的相关需求。本提案旨在让显式地编写异步、并发 Python 代码更容易、更 Pythoinc,并以此满足前述需求。
提案建议使协程成为 Python 中完全独立的新概念,并引入新的支持语法。最终的目的是在 Python 中建立一个简洁通用的异步编程心智模型,并使它尽可能接近同步编程。
在本提案中,假设异步任务都使用类似内置模块 asyncio.events.AbstractEventLoop
中的事件循环进行编排和协调。但是,本提案与任何特定的事件循环实现无关,只与使用 yield
作为调度信号的协程相关,也就是说协程会在事件(例如 IO)完成前保持等待。
我们相信,本提案能够让 Python 在快速增长的异步编程领域中继续保持竞争力,因为很多其他语言已经或计划采用近似的特性:2,5,7,8,10。
注:这部分是修订内容,可以放在最后阅读。
__aiter__
协议。在 3.5.2 之前,__aiter__
返回一个可以被解析成 异步迭代器 的 可等待对象(awaitable)。从 3.5.2 开始,__aiter__
直接返回异步迭代器。如果在 3.5.2 中使用旧协议,会抛出一个 PendingDeprecatioWarning
异常。而在 3.6 中,旧的 __aiter__
协议仍旧会被支持,但会抛出一个 DeprecationWarning
异常。最终,在 3.7 中,旧的 __aiter__
协议将被废弃,如果 __aiter__
返回的不是异步迭代器,则会引发 RuntimeError
。可以通过 引用 19 和 引用 20 来获取更多细节。当前版本的 Python 支持通过生成器来实现协程(PEP 342),PEP 380 中引入的 yield from
语法进一步增强了这一特性。但是这种方案有很多缺点:
yield
或 yield from
语句。在重构这些函数时,如果删除或新增了 yield
相关语句就可能会导致一些不明显的错误;yield
语法支持的地方进行异步调用,无法异步调用类似 with 或 for 这样的语句,限制了可用性。本提案使协程成为 Python 语言的一种原生特性,并且清晰地将其与生成器区分开。这样做不仅消除了生成器与协程之间的歧义,还可以不依赖特定库直接定义协程。同时也提升了 linters 或 IDE 静态代码分析和重构的能力。
原生协程以及相关新语法使得在异步操作中定义上下文管理器和可迭代协议成为可能。稍后会在提案中提及:新的 async with
语句允许 Python 程序在进入或退出上下文上时执行异步调用,而新的 async for
语句可以在迭代器中执行异步调用。
规范章节引入了新的语法和语义,以增强 Python 对协程的支持。
本规范假定阅读者已经了解此前 Python 中协程的实现( PEP 342 和 PEP 380)。本规范涉及的语法修改动机来自 asyncio 模块提案(PEP 3156)和 Cofunctions 提案(PEP 3152,现已被本规范否决)。
在后文中,将使用「原生协程」来指代使用新语法声明的协程,使用「生成器式协程」指代基于生成器语法的协程。
原生协程声明语法如下:
1 | async def read_data(db): |
它的主要特性有:
async def
声明的函数一定是协程,即使内部不包含 await
;async
函数中使用 yield
或 yield from
会引发 SyntaxError
异常;CO_COROUTINE
:用于标记原生协程;CO_ITERABLE_COROUTINE
:使生成器式协程与原生协程兼容(由 types.coroutine 函数设置)。StopIteration
会被 RuntimeError
代替,对于常规生成器来说,这种行为会在后续过程中支持(详情请看 PEP 479);await
直接调用原生协程,当它被垃圾回收时会抛出一个 RuntimeWarning
(点击 用于调试的特性 了解更多);types
模块中新增了一个名为 coroutine(fn)
的函数。它能够帮助「asyncio 中现有的生成器式协程」与「本提案引入的原生协程」实现相互兼容:
1 |
|
1 | # CPython 3.10 Lib.types.py |
如果 fn
是生成器函数, types.coroutine()
会在它的 code object 中添加 CO_ITERABLE_COROUTINE
标志,使其返回一个协程对象。
如果 fn
不是生成器函数,types.coroutine()
会对齐进行包装。如果 fn
返回一个生成器函数,返回的函数会被 _GeneratorWrapper
包装。
type.coroutine()
不会为生成器函数附加 CO_COROUTINE
标志,以便区分「原生协程」和「生成器式协程」。
await
表达式用来获取一个协程执行的结果:
1 | async def read_data(db): |
await
与 yield from
近似,会暂停 read_data 函数的执行直到可等待对象 db.fetch
完成并返回结果。await
使用 yield from
实现,但是多了一个验证参数的步骤。await
后只能跟一个 可等待对象(awaitable),可以是以下选项之一:
types.coroutine()
装饰的函数中返回的生成式协程对象;__await__
方法的对象,且该方法需要返回一个迭代器;tp_as_async.am_await
函数的对象,该函数返回一个迭代器(类似 __await__
方法)。关于第三点一些延伸内容:任何 yield from
调用链都会以 yield
收尾,这是 Futures 执行的必要条件。由于协程本质上是一个特殊的生成器,因此每个 await
都会被 await
调用链上的某个 yield
挂起(详情请参考 PEP 3156)。为了在协程上实现这种行为,一个名为 __await__
的新魔术方法被添加进来。例如,在 asyncio 中, 要想在 await
语句中使用 Future,唯一要做的就是在 asyncio.Future
类中添加 __await__ = __iter__
。后续章节中,称带有 __await__
方法的对象为类 Future 对象。如果 __await__
返回迭代器之外的东西,会抛出 TypeError
异常。
在原生协程外部使用 await
会抛出 SyntaxError
异常(就像在一般函数外调用 yield
一样)。
不在 await
关键字后使用可等待对象会抛出 TypeError
异常。
await
关键字被定义为:
1 | power ::= await ["**" u_expr] |
其中「primary」代表语言中最主要的操作,其语法为:
1 | primary ::= atom | attributerf | subscription | slicing | call |
与 yield
和 yield from
不同,大多数情况下 await
表达式不需要被圆括号包裹。此外,yield from
允许将任何表达式作为参数,甚至可以 yield from a() + b()
,它会被解析为 yield from (a() + b())
。这看起来很反常识就像一个 BUG 一样,因为一般来说,算数操作不会产生可等待对象。为了避免此类问题在 await
表达式中再次出现,await
的优先级被设定为低于 []
, ()
, .
但高于 ``**。
具体优先级如下(从上到下,从低到高):
Operator | Description |
---|---|
yield x, yield from x | Yield 表达式 |
lambda | Lambda 表达式 |
if - else | 条件语句 |
or | 布尔 或 |
and | 布尔 且 |
not x | 布尔 非 |
in, not in, is, is not, <, <=, >, >=, !=, == | 比较,包括成员测试和身份测试 |
| | 位运算 或 |
^ | 位运算 异或 |
& | 位运算 且 |
<<, >> | 位运算 左移和右移 |
+, - | 加减运算 |
*, @, /, //, % | 乘、矩阵乘、除、地板除、取余 |
+x, -x, ~x | 正、负、按位取反 |
** | 幂运算 |
await x | await 表达式 |
x[index], x[index:index], x(args...), x.attribute | 索引、切片、调用、属性 |
(expressions...), [expressions...], {key: value...}, {expressions...} | 元组生成器、列表生成器、字典生成器、集合生成器 |
有效调用:
Expression | Will be parsed as |
---|---|
if await fut: pass | if (await fut): pass |
if await fut + 1: poass | if (await fut) + 1: pass |
pair = await fut, 'spam' | pair = (await fut), 'spam' |
with await fut, open(): pass | with (await fut), open(): pass |
await foo()['spam'].baz()() | await ( foo()['spam'].baz()() ) |
return await coro() | return ( await coro() ) |
res = await coro() ** 2 | res = (await coro()) ** 2 |
func(a1=await coro(), a2=0) | func(a1=(await coro()), a2=0) |
await foo() + await bar() | (await foo()) + (await bar()) |
-await foo() | -(await foo()) |
无效调用:
Expression | Shoud be written as |
---|---|
await await coro() | await (await coro()) |
await -coro() | await (-coro()) |
注:关于上下文管理器的内容可以参考:WeeklyPEP-2-PEP343-with 语句-overview
所谓异步上下文管理器,是一种能够在进入或退出上下文时调用异步代码的上下文管理器。为了实现它,本规范单独为异步上下文提出了一个新协议,此协议由两个新的魔术方法组成:__aenter__
和 __aexit__
。它们都必须返回一个可等待对象。
异步上下文管理器的示例:
1 | class AsyncContextManager: |
本规范规定的异步上下文管理器声明方式如下:
1 | async with EXPR as VAR: |
语义上等同于:
1 | mgr = (EXPR) |
与普通的 with
语句一样,可以在单个 async with
语句中指定多个上下文管理器。
不能将没有实现 __aenter__
和 __aexit__
的普通上下文管理器传递给 async with
。在 async def
函数之外使用 async with
会抛出 SyntaxError
异常。
通过异步上下文管理器可以很方便的在协程中实现数据库事务管理器:
1 | async def commit(session, data): |
也可以很简洁的使用锁:
1 | async with lock: |
代替之前的:
1 | with (yield from lock): |
所谓异步迭代器,是一种可以在 iter 和 next 方法中调用异步代码的迭代器。要想实现它:
__aiter__
方法(如果是通过 CPython C API 定义则需要定义 tp_as_async.am_aiter
slot 代替 __aiter__
);__anext__
方法(如果是通过 CPython C API 定义则需要定义 tp_as_async.am_anext
slot 代替 __anext__
);__anext__
必须在适当的时候抛出 StopAsyncIteration
异常。异步迭代器示例:
1 | class AsyncIterable: |
1 | async for TARGET in ITER: |
语义上等同于:
1 | iter = (ITER) |
在 async for
后使用未实现 __aiter__
方法的常规迭可迭代对象会抛出 TypeError
异常,在 async def
外使用 async for
会抛出 SyntaxError
异常。
与常规 for
语句一样,async for
也有一个可选的 else
字句。
通过异步迭代器可以在迭代过程中异步缓冲数据:
1 | async for data in cursor: |
其中,cursor
是一个异步迭代器,每迭代 N 次就会从数据库中预取 N 行数据。
下面的代码实现了异步迭代协议:
1 | class Cursor: |
然后 Cursor
类可以像这样被使用:
1 | async for row in Cursor(): |
等同于下面这段代码:
1 | i = Cursor().__aiter__() |
下面的示例是一个通用的工具类,它能够将常规迭代器转换为异步迭代器。虽然这不是一个会经常使用的操作,但是这个示例代码说明了常规迭代器和异步迭代器之间的关系:
1 | class AsyncIteratorWrapper: |
为什么需要 StopAsyncIteration
也就是为什么不继续使用 StopIteration
。协程的本质是生成器,所以在 PEP 479 之前,下面的两段代码没有本质上的不同:
1 | def g1(): |
和
1 | def g2(): |
由于 PEP 479 被接受并且在协程中默认启用,下面的示例代码将会使用 RuntimeError
包裹 StopIteration
:
1 | async def a1(): |
因此通知外部代码迭代结束的唯一方案就是抛出一个 StopIteration
以外的异常,也正是因为这样才需要新增一个内置的 StopAsyncIteration
异常。此外,根据 PEP 479 中的定义,所有在协程中抛出的 StopIteration
异常都会被封装在 RuntimeError
中。
本小节仅适用于带有 CO_COROUTINE
的原生协程,即通过 async def
语法定义的协程。asyncio 中现有的生成器式协程的行为保持不变。
为了确保协程与生成器作为不同的概念处理需要付出很大的努力:
__iter__
和 __next__
方法。因此,它不能通过 iter()
,list()
,tuple()
或其他内置方法迭代,同样不能在 for .. in
中使用。若要强行在原生协程中实现 __iter__
或 __next__
会抛出 TypeError
异常;yield from
加原生协程返回正常的生成器,这个行为会抛出 TypeError
异常;yield from
加原生协程返回生成器式协程(在 asyncio 代码中必须使用 @asyncio.coroutine
);inspect.isgenerator()
和 inspect.isgeneratorfunction()
在接收原生协对象和原生协程方法时需要返回 False
。在底层实现上,协程继承自生成器共享实现代码。所以,协程类似生成器拥有 throw()
,send()
和 close()
方法,StopIteration
和 GeneratorExit
在协程中也起相同的作用(尽管 PEP 479 默认在协程中启用)。协程的 throw()
和 send()
方法被用来将值或异常传递给类 Future 对象。
更多细节请看 PEP 342,PEP 380 和 Python 文档相关章节。
注:asyncio.coroutine
在 Python 3.8 之后被标记为废弃,并在 Python 3.11 正式删除。
注:被标记为废弃的是 asyncio.coroutine
而不是 types.coroutine
注:这一小节的内容我看完之后有点犯迷糊,不知道他在表述什么事情。
新手容易犯的一个错误是忘记可以在协程中使用 yield from
:
1 |
|
为了调试这类错误,asyncio 中有一种特殊的调试模式,其中 @coroutine
装饰器使用一个特殊的对象包装所有传递进来的函数,这个对象的析构函数会记录警告日志。每当被包装的生成器被 GC 进行垃圾回收时,就会产生一条详细的日志信息,其中包含该装饰器确切的定义位置、被回收位置的堆栈跟踪等信息。封装对象还提供了一个方便的 __repr__
函数,一种包含有关生成器的详细信息。
问题是如何启动这些调试功能。调试功能在生产环境下应该是不可用的,所以 @coroutine
装饰器根据操作系统环境变量 PYTHONSYNCIODEBUG
来判断是否起作用。这样就可以在运行 asyncio 程序时使用 asyncio 自带的函数。EventLoop.set_debug
(一种不用的调试工具)对 @coroutine
装饰器的行为没有影响。
为了使协程就成为与生成器不同的原生概念:
RuntimeWarning
异常;sys
模块中添加两个新函数:set_coroutine_wrapper
和 get_coroutine_wrapper
。它们的作用是在 asyncio 或其他框架中启用高级调试功能(例如显示创建协程的具体位置,以及更详细的垃圾回收堆栈跟踪)。types.coroutine(gen)
:点击 types.coroutine() 了解更多;inspect.iscoroutine(obj)
:如果 obj
是原生协程对象,返回 True
;inspect.iscoroutinefunction(obj)
:如果 obj
是原生协程函数,返回 Ture
;inspect.isawaitable(obj)
:如果 obj
是可等待对象,返回 True
;inspect.getcoroutinestate(coro)
:返回原生协程对象的当前状态(inspect.getfgeneratorstate(gen)
的逆向函数);inspect.getfgeneratorstate(gen)
:返回本地协程独享的局部变量与其值的映射(inspect.getcoroutinestate(coro)
的逆向函数);sys.set_coroutine_wrapper(wrapper)
:允许拦截原生协程的创建,在原生协程创建时调用 wrapper
。wrapper
可以是「一个接受一个参数(一个协程对象)的可调用对象」或是 None
。如果是 None
则会重置之前定义的 wrapper
,如果调用多次,新的 wrpaaer 将取代之前的。该函数是线程绑定的;sys.get_coroutine_wrapper()
:返回通过 sys.set_coroutine_wrapper
设置的 wrapper
,如果没设置则返回 None
。该函数是线程绑定的。为了更好的与现有框架(如 Tornado,参考 引用 13)和编译器(如 Cython,参考 引用 16)集成,新增了两个抽象基类:
collections.abc.Awaitable
:为类 Future 对象创建的基类,实现了 __await__
方法;collection.abc.Coroutine
:为协程对象创建的基类,实现了 send(value)
,throw(type, exc, tb)
,close
和 __await__()
方法。注意,带有 CO_ITERABLE_COROUTINE
标志的生成器式协程没有实现 __await__
方法,因此不是 collections.abc.Coroutine
或 collections.abc.Awaitable
基类的实例:
1 |
|
为了能更简单地测试指定对象是否支持异步迭代,又引入了另外两个基类:
collections.abc.AsyncIterable
:测试是否存在 __aiter__
方法;collections.abs.AsyncIterator
:测试是否存在 __aiter__
和 __anext__
方法。Navite coroutine function,通过 async def
定义的协程函数,点击 原生协程声明语法 了解更多。
Navite coroutine,从原生协程函数返回的内容,点击 [await 表达式](#await 表达式) 了解更多。
Generator-based coroutine function,基于生成器语法的协程,更常见的示例是使用 @asyncio.coroutine
定义的函数。
Generator-based coroutine,通过生成器式协程函数返回的内容。
Coroutine,原生协程或生成器式协程。
Coroutine object,原生协程对象或生成器式协程对象。
Future-like object,拥有 __await__
方法的对象或拥有 tp_as_async->am_await
函数的 C Object,且该函数或方法返回一个迭代器。可以在协程中作为 await
表达式的参数。在协程中 await
类 Future 对象时,协程会被推迟直到类 Future 对象的 __await__
完成并且返回结果,点击 [await 表达式](#await 表达式) 了解更多。
Awaitable,类 Future 对象或协程对象。点击 [await 表达式](#await 表达式) 了解更多。
Asynchronous context manager,拥有 __aenter__
和 __aexit__
方法的对象,可以搭配 async with
使用,点击 [异步上下文管理器与 async with](#异步上下文管理器与 async await) 了解更多。
Asynchronous iterable,拥有 __aiter__
方法的对象,该方法返回一个异步迭代器对象。可以搭配 async for
一起使用,点击 [异步迭代器和 async for](#异步迭代器和 async for) 了解更多。
Asynchronos iterator,拥有 __anext__
方法的对象,点击 [异步迭代器和 async for](#异步迭代器和 async for) 了解更多。
tokenizer.c
文件是 CPython 源码中的一个文件,主要负责实现 Python 解释器中的词法分析器。
为了解决 async
和 await
的向后兼容性问题,需要对 tokenizer.c
进行如下修改:
async def
NAME
标记组合;async def
块进行词法分析时,会将 async
NAME
标记替换为 ASYNC
,将 await
NAME
标记替换为 AWAIT
;def
块进行词法分析时,会保持 async
和 await
NAME
不变。这种实现方式能够让新语法(只能在 async
函数中使用)与现有代码无缝结合。一个既包含 async def
又包含 async
属性的示例:
1 | class Spam: |
为了兼容新语法,需要确保在现有的内置模块中不存在与 async
和 await
关键字冲突的命名,且新的原生协程需要兼容之前存在的生成器式协程。
注:在本 PEP 实施之前,asyncio 库中已经存在了一个名为 async
的函数。
asyncio
模块进行了调整和测试,使现有协程方案与新语法保持兼容,保证 100% 向后兼容,即现有代码能够在新版本中正常运行。
进行调整的主要有:
@asyncio.coroutine
装饰器使用新的 types.coroutine()
函数;asyncio.Future
类添加 __await__ = __iter__
;ensure_future()
作为 async()
函数的别名,废弃 asyncio
中的 async()
函数。 yield from
原生协程对象不能返回普通的生成器(点击 与生成器的不同之处 了解更多),因此建议在开始使用新语法之前,确保所有生成器式协程都使用 @asyncio.coroutine
进行装饰。
在 CPython 中没有使用 await
。
async
关键字主要是被 asyncio 模块占用。为了解决这个问题,需要将 asyncio 模块中的 async()
函数重命名为 ensure_future()
(点击 asyncio 了解更多)。
async
关键字的另一个占用场景是 Lib/xml/dom/xmlbuilder.py
中为 DocumentLS
类定义的 async = False
属性。没有针对这一属性的文档或测试文件,CPython 中的其他地方也没有使用这个属性。现在它被一个 getter 取代,调用 getter 会引发一个 DeprecationWarning
异常并通过异常信息建议使用 async_
属性代替此属性。 除此以外,CPython 代码库中没有其他的 async 属性被记录或使用。
语法的变化相当小:
1 | decorated: decorators (classdef | funcdef | async_funcdef) |
注:根据原文,本来计划在 Python 3.5 或 3.6 中废弃 async
和 await
,并在 3.7 中过渡到一个更合适的关键字,但从当下来看这个计划应该是没有实施。
Gregory Ewing 提出的 PEP 3152 提供了另一种机制来实现协程(或者称为 cofunctions),其中一些关键的要素:
codef
。Cofunction 总是一个生成器,即使没有 cocall
表达式在其内部。类比 async def
;cocall
。只能够被用在 cofunction 内部。类比 await
;cocall
关键字调用;cocall
语法需要在其后方使用圆括号;cocall f(*args, **kwargs)
在语义上等同于 yield from f.__cocall__(*args, **kwds)
。相关语法定义:
1 | atom: cocall | <existing alternatives for atom> |
与本提案的不同之处:
__cocall__
一致的方法。__cocall__
方法会被 cocall
表达式会调用并将其结果传递给 yield from
,虽然 __await__
方法与 __cocall__
类似,但 __await__
只用于定义类 Future 对象。await
的定义几乎与 yield from
相同(后来强制规定 await
只能出现在 async def
中)。但 await
可以很简洁地使用 await future
的方式调用,而 cocall
总是需要圆括号辅助; @asyncio.coroutine
装饰器,来将所有函数封装在一个带有 __cocal__
方法的对象中,或在生成器上实现 __cocall__
。要在生成器式协程中调用 cofunctions,需要使用内置的 costart(cofunc, *args, **kwargs)
;cocall
关键字调用 ,因此自动避免在生成器式协程中忘记使用 yield from
的常见错误。本提案是使用其他方法来解决这一问题的,点击 用于调试的特性 了解更多。cocall
调用 cofunction 的一个缺点是,如果决定实现协程生成器(使用 yield
或 async yield
表达式的协程),就不需要 cocall
关键字来调用。因此最终会使得协程拥有 __cocall__
而没有 __call__
,协程生成器拥有 __call__
而没有 __cocall__
。async for
和 async with
的设计。下面这段代码:
1 | await fut |
需要这样表达:
1 | cocall fut() |
通过 async for
关键字可以实现一种协程生成器的概念,即一个带有 yield
或 yield from
的协程。为了避免与一般的生成器混淆,可能需要在 yield
关键字前加上 async
关键字,而 async yield from
会抛出 StopAsyncIteration
异常。
虽然协程生成器的概念可能实现,但是不应该在本提案中讨论。这是一个高阶的概念,会使当前生成器的实现发生巨大的变动,应该权衡利弊,仔细考虑。这个问题应该由一个单独的 PEP 进行讨论。
在众多编程语言中,async/await 已经不是一个新鲜的概念了:
这是一个巨大的优势,因为这些语言的使用者已经有了使用 async/await 的经验,而这使得在一个项目中使用多种语言(例如在 Python 中使用 ECMAScript 7)变得更加容易。
__aiter__
返回的不是可等待对象PEP 492 在 CPython 3.5.0 被接受,并且新增了 __aiter__
方法,该方法返回一个解析为异步迭代器的可等待对象。
在 3.5.2 中(PEP 492 被临时接受),__aiter__
协议被更新为直接返回异步迭代器。
这么做的目的是在 Python 中实现异步生成器,点击 引用 19 和 引用 20 了解更多。
虽然可以只实现 await
表达式,并且将至少拥有一个 await
的函数视为协程,但这样做会增加 API 设计、代码重构和长期支持的难度。
假设 Python 只有 await
关键字:
1 | def useful(): |
如果对 useful()
函数进行重构,删除其内部所有 await
表达式,它就会变成一个普通 Python 函数,所有依赖于它的代码(例如 important()
)都会产生异常。要想解决这个问题,必须引入类似 @asyncio.coroutine
的装饰器。
直接使用 async name(): pass
可能比 async def name(): pass
更有吸引力,因为这种方式输入的字符更少。但它打破了 async def
、async with
和 async for
之间的一致性,其中 async
都是修饰符,表示语句是异步的。此外,它与现有语法更契合。
async
是一个形容词,因此更适合作为修饰词与其他关键字搭配。await for/with 看起来更像是等待 for
或 with
语句执行完成。
async
关键字是一个语句的修饰符。在其他编程语言中常见的 static
、public
、unsafe
等关键字是一个很形象的类比。async for
是异步的 for
语句,async with
是异步的 with
语句,async def
是异步函数。
将 async
放在其他关键字后面很可能会引起混淆,例如 for async item in iterator
可以理解为从 iterator 中遍历异步的 item。
将 async
放在 def
、with
和 for
前面,还能使语言语法更加简单。同时,async def
也能让使用者更方便地区分协程与普通函数。
__future__
from __future__ import feature
形式的导入被称为 future 语句。 它们会被 Python 编译器当作特例,通过包含 future 语句来允许新的 Python 特性在该特性成为语言标准之前发布的模块中使用。
过渡计划 章节介绍了词法分析器做了哪些修改使其仅在 async def
块才中将 async
和await
作为关键字处理。因此,async def
发挥了模块级编译器声明(类似 from __future__ import async_await
)的作用。
一个备选方案是使用 async
前缀,但是为了让新的魔术方法和原有魔术方法保持尽可能高的相似性,最终选择了方法名更短的方案。
存在一个异步迭代器和异步上下文管理器的备选方案,提议在声明中添加 async
关键字来复用现有的魔术方法:
1 | class CM: |
这个方案有以下缺点:
with
中使用,又可以在 async with
中使用的对象;__enter__
或 __exit__
中返回类 Future 对象;无论是现有的生成器式协程还是本提案提出的原生协程都更希望使用者能够明显地看到代码可能阻塞的位置。让现有的 for
和 with
语句识别异步迭代器和异步上下文管理器会不可避免地引入隐式阻塞点,从而导致代码变得更难理解。
可以提供异步推导式,但是这个语法不在本提案的讨论范围内。
注:PEP 530 定义了异步推导式,可以在 3.6 之后的版本使用。
可以提供异步 lambda 函数,但这个语法不在本提案的讨论范围内。
注:目前 Python 还没有解锁异步 lambda。
本提案不会对 Pyhton 本身的性能产生明显影响。下面是 Python 官方基准测试 的输出结果:
1 | python perf.py -r -b default ../cpython/python.exe ../cpython-aw/python.exe |
使用修改后的词法分析起解析 Python 文件没有明显的速度减慢:解析一个 12MB 的文件(Lib/test/test_binop.py
重复 1000 次)所需时间(与之前)相同。
下面的微型基准测试用于确定异步函数和生成器之间的性能差异:
1 | import sys |
结果是没有观察到明显的性能差异:
1 | binary(19) * 30: total 53.321s |
注 depth = 19 以为着 1048575 次调用。
可以通过 引用 15 追踪具体实施过程,它在 2015-5-11 提交。
本文的主体内容大部分来自对 PEP 318 原文的翻译,剩余部分是本人对原文的理解,在整理过程中我没有刻意地区分二者,这两部分被糅杂在一起形成了本文。因此请不要带着「本文的内容是百分百正确」的想法阅读。如果文中的某些内容让你产生疑惑,你可以给我留言与我讨论或者对比 PEP 318 的原文加以确认。
注:PEP 318 创建于 2003-06-05,Python 2.4
本文档的主要目的是描述装饰器语法和做出相关决策过程。它既不试图涵盖全部潜在的替代语法,也不试图详尽地罗列出每种语法的优缺点。
当前(Python 2.4 之前)转换一个函数或方法(例如将它们定义为一个类方法或静态方法)的方案很笨拙,并且可能会导致降低代码的可读性。理想情况下,这类转换应该与函数或方法的定义同步进行。本 PEP 为函数或方法实现这类转换引入了全新的语法。
当前(Python 2.4 之前)实现一个函数或方法转换的方案是将转换定义在函数声明的后面。 对于一些大型函数来说,这样做会让函数行为的关键部分与函数外部内容形成割裂感,例如:
1 | def foo(self): |
这种方案不仅使得那些长函数的可读性变差,还会使得一个单一的概念存在多次声明,很不 pythonic。一个解决此问题的方案是让函数转换贴近函数自身的声明。新语法的意图就是将装饰器放在函数声明中以替代现有方案:
1 |
|
以这种形式修改类是完全可行的,尽管这样做的受益并没有那么明显。当然,任何可以使用类装饰器完成的事情都可以使用元类完成。但是使用元类是一种高阶的方案,所以「能以一种更简洁明了的方式对类进行简单修改」是有吸引力的。Python 2.4 中仅添加了函数/方法装饰器。
PEP 3129 建议从 Python 2.6 开始添加类装饰器。
在 Python 2.2 之后就有两个装饰器(classmethod()
和 staticmethod()
)可以被使用。差不多从这时起,大家便认为 Python 最终会在语言层面为它们添加语法上的支持。也许你会好奇,为什么达成最终的共识如此困难(从 Python 2.2 到 Python 2.4)。函数装饰器最佳实现方案相关的讨论在 comp.lang.python 和 python-dev 邮件列表中一直不断,主要的分歧集中在以下几个问题上:
语法往往比其他任何事情都容易引起更多的争论,[PEP 308] 中与三元运算符语法相关的讨论是另一个例子。
人们普遍认为,以当前的状态,为装饰器提供语法支持是可取的。Guido 也在第十届 Python 大会的 DevDay 主题演讲中提到了对装饰器的语法支持,尽管 他后来说 这只是他在那里半开玩笑地提出的几个拓展之一。在会议结束不久之后,Michael Hudson 在 python-dev 上发布了这个 主题,并将最初的方括号语法归因于 Gareth McCaughan 在 comp.lang.python 上的 早期提案。
类装饰器似乎会顺理成章的成为下一个目标,因为类的定义和函数的定义在语法上是相似的,但 Guido 任然保持怀疑,因此类装饰器几乎可以确认不会在 Python 2.4 中出现。
有很多人抱怨为这个特性选择「装饰器」这个名字。其中最主要的原因是这个名字与 GoF 书(设计模式:可复用面向对象软件的基础)中所阐述的概念并不一致。选择「装饰器」这个名字更多的是由于它在编译器领域的使用——语法树被遍历和注释。很可能会出现一个更好的名字(目前看来并没有)。
注:译者猜测在设计时还没有明确装饰器这个概念所以原文使用 wrapper 来表示被设计的主体(也就是装饰器)。
新语法应该:
classmethod()
以及 staticmethod()
。这项需求同时意味着必须能够向 wrapper constructor 传递参数;Andrew Kuchling 在他的博客(已经无法访问)中有一些关于动机和用例的讨论的链接,特别值得注意的是 Jim Huginin 的用例列表。
在 Python 2.4a2 中实现的函数装饰器的语法是:
1 |
|
这相当于:
1 | def func(arg1, arg2, ...) |
没有对 func
的多次赋值,装饰器就在函数声明的周围,@
符号能够提醒使用者:这里有一些新特性在起作用。
从上到下逐个起作用的逻辑源自数学中函数应用的通常顺序。在数学中,结构是 (g o f)(x)
的函数会被转换为为 g(f(x))
。在 Python 中,@g @f def foo()
会被翻译为 foo=g(f(foo))
。
装饰器语句所能接受的内容是有限的(任何表达式都不起作用)。Guido 喜欢这样,因为更符合直觉。
当前语法还允许装饰器声明调用一个返回装饰器的函数:
1 |
|
这相当于:
1 | func = decomaker(argA, argB, ...)(func) |
这个语法生效的逻辑是将 @
符号后面的内容视作一个表达式(语法上被限制为:只能是一个函数),并且无论该表达式返回什么都会被调用。
目前已经提出了大量不同的语法,与其试图逐一讨论这些语法,不如将「语法讨论」分成几个方面。试图对每种可能的语法进行讨论是一种疯狂的行为,并且会产生一个非常臃肿的 PEP。
第一个值得讨论的语法问题是:装饰器的位置。下面的代码示例中会使用 Python 2.4a2 中的最终确定的 @
符号作为装饰器符号。
1 |
|
人们对这种方案有一些反对意见,其中最主要的是:这是(当时) Python 中第一例某行代码会对下一行代码产生影响的案例。在 2.4a3 版本中要求每行一个装饰器(在 2.4a2 版本中,可以在同一行指定多个装饰器),而 2.4final 的最终决定是每行一个装饰器。也有人抱怨说这种语法会是的在使用多个装饰器时变得笨重。不过有人指出,在一个函数上使用大量装饰器的可能性很小,因此这不是一个大问题。
这种方案的优点是装饰器位于函数声明外部,这使得人们能够直观地理解装饰器会在定义函数时执行。另一个优点是,在函数定义上添加前缀符合在代码本身之前了解代码语义变化的要求。使用者可以正确并快速地理解代码的语义,而不必在阅读代码时反复查看上下文。
Gudio 也更偏向于将装饰器定义在 def 的上一行,因为长的参数列表意味着装饰器可能被忽略。
1 | def @classmethod foo(arg1, arg2): |
这个方案也一些反对意见。首先,它很容易破坏源代码的「可重命名性」,你不再能通过搜索 def foo(
并找到函数定义。第二个更严重的反对意见是,在s使用多个装饰器的情况下语法会显得及其笨重。
:
之前1 | def foo(arg1, arg2) @classmethod: |
Gudio 总结了反对这个方案的几种论点(其中很多也适用于前一种形式):
1 | def foo(arg1, arg2): |
这种形式的主要缺点是,它需要“窥视”函数内部才能确定装饰器。此外这些位于函数内部的内容,在运行时也不会执行。Gudio 认为 docstring 不是一个很好的反例,并且使用 docstring 来放置装饰器很有可能会使得最终不得不把文档字符串移动到函数声明外部。
1 | decorate: |
这种形式会导致使用装饰器函数和没使用装饰器的函数的缩进不一致,另外被装饰的函数的声明需要写在第三层缩进。
1 |
|
反对这种语法的主要理由是 @
符号从未在 Python 中使用过(但是在 IPython 和 Leo 中都有使用),并且 @
符号没有意义。另外一种反对意见是,这种方案浪费了一种从未使用的字符(一个有限的集合),这些字符应该被用在更重要的场合。
1 | |classmethod |
这是 @decorator
的变体,它的优点是不会破坏 IPython 和 Leo,主要缺点是符号 |
看起来既像大写的 I
又像小写的 l
。
1 | [classmethod] |
列表语法最重要的缺点是它在 Python 中是有具体含义的,其次它也不能很好地表明该表达式是一个装饰器。
1 | <classmethod> |
这些替代方案都没有获得太多支持。使用双方括号的替代方案只是为了表明这是一个装饰器不是一个列表,并没有使解析变得更容易。尖括号的替代方案也存在解析问题,因为 <
和 >
都有独立的含义,对于装饰器来说 >
可能是一个大于号而不是装饰器定义的关闭符号。
decorate()
的方案是不实现新的语法,而是实现一个能够使用内省来控制其后面紧跟的函数的内置函数。Jp Calderone 和 Philip Eby 都实现了这样的函数。Gudio 非常坚决地反对这样(不使用新的语法)做,这种方案带来了极大的不确定性。
这个想法是 comp.lang.python 的共识替代方案,在下面的 [社区共识](# 社区共识) 中有更多关于这一点的内容。Robert Brewer 写了一份详细的 J2 提案文件(无法访问),概述了支持这种形式的论点。初始问题是:
from __future__ import decorators
语句。using
作为共识选择出现,并在提案和实现中使用。几天后,Guido 基于 两个主要理由 拒绝了这项提议。
在 维基页面 上还有很多其他的变体和提案。
在 Java 的历史中,@
最初在 Javadoc comments 中使用被作为标记,后来在 Java 1.5 中用于 annotations,类似于 Python 装饰器。在此之前,@
从未在 Python 中用作标记,这样的代码不能被早期的 Python 版本解析,可能会导致微妙的语义错误。这也意味着什么是装饰器,什么不是的模糊性被消除了。也就是说,@
仍然是一个相当随意的选择。有些人建议使用 |
。
在原文中还有两部分分别描述了最终实施的过程和一些示例,这里我就不展示了,感兴趣的可以自行翻阅原文。
最近春节假期在家闲着无聊,就顺着之前阅读 Django Url 模块的劲头开始读起了 Django 的源码和文档。在阅读 Django 文档 的过程中,我第一次发现 Django 文档实际的组织方式,于是便有了这篇文章。
我们打开 Django 文档时一般看到的都是 根目录,在根目录下 Django 很贴心地使用副标题分割出了 16 个话题,并在相应的话题下面对话题所包含的内容进行展开和索引。这很好,当我对 Django 中某个部分(模块)产生疑惑的时候我都能够通过这个页面快速定位到对应的页面。我一度认为 Django 的文档就是通过话题进行组织的,但在我上次想要通读 Django 文档时,我从页面右侧我老早就发现但是从未点击的目录按钮进入到了 Django 文档真实的组织架构页面:目录页。
根据 目录页 可以看出 Django 文档实际被分为了 9 部分(忽略索引和词汇表),分别是:
竖线前是章节的中文翻译,竖线后是对应的 url path。
根目录 实际上是将 目录页 中的大部分内容通过话题的形式进行重新组合的产物。
适合场景:第一次了解 Django,想要快速掌握 Django 的使用方法。
这时候可以阅读 开始 部分,里面的教程几乎是每个 Django 入门者的必经之路。
适合场景:遇到一些比较生僻的概念,直接丢到搜索引擎没办法快速理解。
这时候就可以尝试将这个概念丢到 Django 文档右上角的搜索框中,它会为你列出在文档中所有出现过这个概念的地方。
有两个需要注意的点:
适合场景:希望能够准确掌握 Django 中某个概念 50% 以上的内容。
这时候可以通过 根目录 配合目录页的 使用 Django 和 API 参考 阅读相关的内容。
这样做能够使你快速从零开始了解一个 Django 概念,并掌握其 50% 以上的内容(如何使用),剩下的部分(具体代码实现)则需要阅读 Django 源码来补齐。
适用场景:希望对 Django 有一个全面了解,进一步掌握 Django。
这时候可以以根目录中 模型层 以下的主题为索引,从目录页的 使用 Django 和 API 参考 找到相应的章节,先读 使用 Django 中的概念讲解,再读 API 参考 中的接口设计。
Django 文档虽然有较为全面的中文翻译,但是从我阅读的体验来看绝大多数应该是机翻,而且因为文档中有大量的引用格式,导致在汉化的过程中会存在解析失败的情况,所以有能力的最好还是直接阅读英文版。实在不行我也建议中英文混合阅读,可以中文英文各开一个页面,也可以使用能够保留英文原文的翻译插件。
在 Django 内部 | internals 中有对 Django 社区的全面介绍,这里只简单介绍几个常用的:
注:上面的几个社区平台基本全是英文的,所以需要一定的英语阅读方法;
注:本文使用 Django 版本:4.2.x
最近在处理公司接口端(基于 DRF)业务逻辑的时候想要通过 DRF 的 DefaultRouter 定制化一个类似 Swagger 的 API 页面展示,但是在编写路由解析方法的时候却犯了难。之前我能只理解了如何使用 Django urls 模块中的方法生成满足业务需求的路由,但是我还真没研究过怎么收集现有路由,并进行遍历和反向解析,于是便有了此次源码阅读。
本文以 Django 初始化和请求流程为主线,研究在这个过程中 Django 的 urls 模块做了哪些工作,并不是详细讲解 urls 模块下的全部方法。
本章以最常用的 python manage.py runserve
为例,梳理 Django 初始化和请求流程。这里为了阅读体验简化了步骤,想了解更完整的请求流程可搭配 Django 笔记-1-从请求到响应 进行阅读:
1 | python manage.py runserver |
可以看到最后最关键的部分是调用了 django.urls.resolvers.URLResolver(settings.ROOT_URLCONF).resolve(request.path_info)
这样的一个方法,而这一个链式调用是由 django.core.handlers.wsgi.WSGIHandler.resolve_request
产生的,下面我们就以 resolve_request
方法为入口详细分析整个 urls 模块的调用链。
1 | # django.core.handlers.base.BaseHandler(WSGIHandler 的父类) |
Django 文档对于 settings.ROOT_URLCONF 的定义是:
ROOT_URLCONF
默认:未定义一个字符串,代表你的根 URLconf 的完整 Python 导入路径,例如 “mydjangoapps.urls”。可以通过在传入的 HttpRequest 对象上设置属性 urlconf 来覆盖每个请求。详情请参见 Django 如何处理一个请求。
一般情况下就是我们使用 django-admin startproject <projectname>
启动项目后在 <projectname>
目录下的 urls.py 模块,这里为了方便讲解我们模拟这样一个项目:
1 | testapp\ |
其中几个重要文件的内容分别为:
1 | #testapp.views |
通过观察 testproject.urls
不难看出在 Django 项目下注册路由主要是通过 django.urls
模块下的 path,re_path 和 include 三个方法,我们先观察一下这三个方法的定义:
1 | # django.urls.conf.py |
将定义中使用 partial 的部分替换后可得
1 | path = _path(..., Pattern=RoutePattern) |
1 | # django.urls.conf |
1 | # django.urls.conf |
RoutePattern 与 RegexPattern 最后都会被转换为正则匹配,只是 RoutePattern 在定义的时候可以使用特殊的语法定义参数变量,而 RegexPattern 则需要使用正则匹配去表达这些内容,例如 RoutePattern('foo/<int:pk>')
会被转换为 RegexPattern('^foo\\/(?P<pk>[0-9]+)')
。感兴趣的可以看一下 django.urls.resolvers._route_to_regex
方法。
1 | # django.urls.resolvers.py |
URLPattern 与 URLResolver 是不同模式路由匹配方案,URLPattern 用于定义简单路由基本上可以理解为一个萝卜一个坑,一个 URLPattern 只负责一个视图的匹配,而 URLResolver 则是通过命名空间和应用名称将一组路由(这一组路由中也可能只有一个路由)汇集到一起用于匹配。
1 | # django.urls.resolvers.py |
伴随着 URLPattern 与 URLResolver 被理解,我们可以沿着目录开始一层一层的将调用结果出栈,最终返回到 resolve_request 的最后三行:
1 | # django.core.handler.base.BaseHandler.resolve_request |
1 | path('admin/', admin.site.urls), |
原文:Kraken Technologies: How we organise our very large Python monolith
作者:David Seddon from Kraken Technologies
翻译:RyomaHan | 小白
提示:本文是原作者以第一人称书写,翻译时未做更改
本文来自一位 Python 开发者对一个庞大的 Python 项目的代码组织结构的总结。
该项目包含近 3万个 Python 文件,由全球 400 多名开发者共同维护。为了应对代码日益增长的复杂性,项目采用了分层架构的设计。即将代码库划分为多个层级,并限制不同层级之间的依赖关系,依赖只能从上层流向下层。
文章详细介绍了该项目的分层结构,以及如何利用 Import Linter 工具来强制执行分层规则。通过追踪被忽略的非法 import 语句数量,可以衡量分层结构实现的进度。
分层架构确实能够有效降低大型项目的复杂度,方便独立开发。但也存在一些缺点,比如容易在高层产生过多代码,完全实施分层需要花费时间等。总体来说,尽早引入分层架构,能够减少后期的重构工作量,是管理大型 Python 项目的一个有效方式。
本文通过一个真实的大规模 Python 项目案例,生动地介绍了分层架构的实施过程、优势和不足,对于管理大型项目很有借鉴作用。
大家好,我是来自 Kraken Technologies 的 Python 开发者, David。我在的 Kraken 工作是维护一个Python 应用,根据最新统计它拥有 27637 个模块的 。是的,你没看错,这个项目拥有近 28K 独立的 Python 文件(不包括测试代码)。我与全球其他 400 名开发人员一同维护这个庞然大物,不断地为它合并新的代码。任何人只需要在 Github 上获得一位同事的批准,就能修改代码文件,并启动软件的部署,该软件在 17 家不同的能源和公用事业公司运行着,拥有数百万的客户群体。
看到上面的描述,你大概率会下意识地认为这个项目的代码肯定无比的混乱。坦白讲,我也会这么想。但事实是,至少在我工作的领域,大量的开发人员可以在一个大型的 Python 项目上高效地工作。实现这个目标的要素有很多,其中许多要素来自文化与规则而非技术,在本篇博文中,我想着重讲一下我们是如何通过优化代码组织结构来实现这一目标的。
如果你已经负责维护某个应用的代码仓库一段时间,肯定会感受到随着时间的推移代码复杂度越来越高。在不断开发与维护的过程中,应用中各部分的逻辑代码混合在一起,独立地分析应用中的某个模块变得越来越困难。这也是我们早期维护代码仓库时遇到的问题,经过研究后我们决定采用分层架构(即将代码库划分成多个组件(也就是层级,后面不再注释),并限制各组件间的引用关系)来应对这一问题。
分层(Layering)是一种较为常见的软件架构模式,在这种模式下不同的组件(即层级,后面不在重复注释)会被以(概念上)栈的形式组织起来。在这个栈中,下层组件不能依赖(引入)其上层组件。
例如,在上图中,C 可以依赖 B 和 A,但不能依赖 D。
分层架构的应用很宽泛,你可以自由地定义组件。例如:你可以将多个可独立部署的服务视作多个组件,也可以直接将项目中不同部分的源码文件视作不同的组件。
依赖关系的定义也很宽泛。通常,只要两个组件间存在直接交叉(即使只发生在概念层级上),我们就认为它们之间存在依赖关系。间接交叉(例如通过配置传递)通常不被视为依赖关系。
分层架构在 Python 项目中的最佳实践是:将 Python 模块作为分层依据,将导入语句视为依赖依据。
以如下项目仓库目录举例:
1 | myproject |
目录中模块之间的嵌套关系是分层的最佳依据。假设,我们决定按照一下顺序进行分层:
1 | # 依赖关系向下流动(即上层可以依赖下层) |
为了满足上述架构的要求,我们需要禁止 payments
中的模块从 shopping_cart
模块中引入内容,但可以从 products
模块中引入内容(参考图 1)。
分层也可以嵌套,因此我们可以在 payments 模块中继续分层,例如:
1 | api |
设置多少分层以及以什么顺序进行排列没有唯一正确的答案,需要我们不断的在实践中总结。但是合理的运用分层架构确实能够有效地降低项目结构的复杂度,使其能够更易于理解和修改。
在我编写这边文章的时候,已经有 17 家不同的能源和公共事业相关的企业购买了 Kraken 的许可证。我们在内部称呼这些企业为 client,并为每一家企业都运行了一个独立的实例。也正因如此,Kraken 的不同实例间形成了一种「同根不同枝」的特点。通俗地讲就是不同实例间的很多行为其实是共享的,但是每个 client 也都有属于自己的定制代码,以满足他们特定的需求。从地域层面来讲也如此,在英国运行的所有 client 之间存在一定的共性(他们属于同类的能源行业),而日本的 Octopus Energy 则不共享这些的共性。
随着 Kraken 平台的成长,我们也在不断地优化着我们的分成架构,来帮助我们更好地满足不同客户的需求。目前的分层的顶层结构大致如下:
1 | # 依赖关系向下流动(即上层可以依赖下层) |
client 组件在结构的顶部。每一个 client 在该层都有一个专属的子包(例如,oede 对应 Octopus Energy Germany)。在此之下的是 territories 组件,用于满足不用国家所需的特定行为,同样为不同地区设置了不同的子包。最底层是 core 组件,包含了所用 client 都会用到的通用代码。我们还制定了一个特别的规则:client 组件下的子包必须是独立的(即不能被其他 client 引用),territories 组件下的子包也是如此。
将 Kraken 以这种分层结构构建之后,我们可以在有限的区域内(例如一个组件的子包)便捷地进行代码的更新和维护。由于 client 组件位于结构的顶部,因此不会有任何其他组件会直接依赖于它,这样我们就能更方便地更改特定 client 有关的内容,而且不必但因会影响到其他 client 的行为。同样,只更改 territories 组件内的一个子包也不会影响到其他的子包。这样,我们就可以快速、独立地进行跨团队开发,尤其是当我们进行的更改只影响少量 Kraken 实例的时候。
虽然引入了分层结构,但我们很快发现,仅仅在理论上论述分层是不够的。开发人员经常会不小心进行分层间的违规引入。我们需要以某种方式确保分层结构的理论能够在代码结构中被遵循,为了达到此目的我们在项目中引入了第三方库 Import Linter。
Import Linter 是一款开源工具,用于检查项目中的引用逻辑是否遵循了指定的结构。首先,我们需要在一个 INI 文件中定义一个描述目标需求的配置,类似这样:
1 | [importlinter:contract:top-level] |
我们还可以使用另外两个配置文件强制不同的 clients、territories 之间相互独立。类似这样:
1 | # 文件 1 |
然后,你可以在命令行运行 lint-import
,它会告诉你项目中是否有任何导入行为违反了我们配置中的要求。我们会在每次拉取代码的时候运行此功能,因此如果有人使用了不合规的导入,检查就会失败,代码也就不会被合并。
上面展示的并不是我们项目全部的配置文件。团队成员可以在应用程序的更深处添加自己的分层,例如:kranken.ritories.jpn 本身就是分层。我们目前拥有超过 40 个配置文件用于规定我们的分层结构。
我们没有办法在确定是由分层架构的第一时间就使整个项目符合架构需求。因此,我们使用了 Import Linter 中的一项特性,该功能允许您在检查非法导入之前忽略对某些导入的检查。
1 | [importlinter:contract:my-layers-contract] |
此后,我们使用项目构建时被 Import Linter 忽略的导入语句的数量作为跟踪技术债完成度的指标。这样,我们就能观察到随着时间的推移技术债的情况是否有所改善,以及改善的速度如何。
上图是我们过去一年多的时间里被我们忽略的有问题的引入语句数量的变化。我会定期分享这张图,想大家展示我们最新的工作进度,并鼓励我们的开发者努力做到完全遵守分层结构的约定。我们对其他几个技术债也使用了这种燃尽图的方法去展示。
现实世界无比的复杂,依赖关系遍布在项目的各个角落。在采用分层架构后,你会经常遇到想要打破现有层级关系的情况,会经常在不经意间从低层级的组件中调用高层级的组件。
幸运的是,总有办法解决这类问题,那就是所谓的 控制反转(Ioc),在 Python 中你可以很容易地做到这一点,只是需要转换一下思维方式。不过使用这个方法会增加「局部复杂性」,但为了让项目整体变得更加简单,这点代价还是值得的。
在分层结构中,层数越高的组件天然地越容易更改。正因如此,我们特地简化了修改特定 clinents 或 territories 的代码流程。另一方面,core 是一切其他代码的基础,修改它就成为了一件高成本、高风险的事情。
高成本、高风险的底层代码修改行为让我们望而却步,促使我们编写更多针对特定客户或地区的高层级代码。最终的结果就是,高层的代码比我们想象中要多的多的多。我们仍在学习如何解决这个问题。
还记得之前提到过的被设置在 Import Linter 特殊配置文件中被忽略的 import 吗?多年过去了,它仍未被全部解决,根据统计还有最少 15 个。最后的这几个 import 也是最顽固、最难以被优化的。
我们需要付出很多的时间才能重构完一个现有项目,所以,越早分层需要面对的麻烦就越少。
Kraken 的分层结构使我们在如此庞大的代码体量下仍旧保持着健康的开发和维护,而且操作难度相对较小,特别是在考虑到它的规模的情况下。如果不对数以万计的模块之间的依赖关系加以限制,我们的项目仓库很可能会像揉乱的线团一样复杂。但是我们选择的代码架构顺利的帮助我们在单一的 Python 代码库中进行大量工作。看似不可能,但这就是事实。
如果你正在开发一个大型的 Python 项目,或者哪怕是一个相对较小的项目,不发试试分层结构,还是那句话:越早分层需要面对的麻烦就越少。
]]>最近公司的一个新项目上马,我被安排来做项目初始化,前端初始化的时候使用了 Vue3 + Tailwind CSS + NaiveUI,在搞基础布局的主题变化时出现了本次插曲。
按照设计,Web PC 端的左侧边栏有一排导航按钮,这些导航按钮在 light 模式下应该是白底,在 dark 模式下应该是 Naive UI 的默认底色。按照需求描述,只需要使用 Tailwind CSS 来实现「仅在 light 模式下修改指定按钮背景色为白色」就行了。
可坑爹的是 Tailwind CSS 只提供了 dark:bg-white
的写法而没有类似 light:bg-white
的写法,按照正常写法可以使用 class="bg-white dark:bg-[#需要的颜色]"
来处理。但是我不希望这样写,因为 dark 模式下 UI 按钮组件默认的底色就是我所需的,所以我没理由再去强调 dark 模式下的底色是什么,我只需要使用类似 light:!bg-white
的写法来强制覆盖 light 模式下 UI 按钮组件的底色就行。
于是我开始了无休止的 AI 辅助编程:
注:这一步我的诉求是让 AI 帮忙直接生成解决方案。
我最开始的思路是直接将问题描述清楚,让 AI 帮忙生成解决方案,于是:
1 | ryomahan [5:26 PM] |
马后炮:其实这里已经将问题的核心点出来了,即 Tailwind CSS 实现类似 dark:
的写法是通过一种叫做 variant
的概念来实现的,Tailwind CSS 本身提供了一些基础写法,并且给出了文档。
我当时着急直接得到解决方案,便以为 <div class="light:bg-white bg-gray-800">...</div>
就是我想要的答案,但是拿到项目代码中测试发现不管用。于是便开始了下一次问答,而无视了这条答案中最重要的那句话:在 Tailwind CSS 中,确实有类似 light:bg-white 这样的写法。这叫做 variant。
在直接提问这个思路下我又尝试问过如下几个问题:
得到的答案都不是我想要的东西,于是我开始转换思路。
注:这一步我的诉求是让 AI 帮忙直接生成解决方案。
经过直接提问发现无法得到我想要的答案,于是我开始尝试从实现原理层面进行引导,让 AI 帮忙生成解决方案,我尝试过如下提问:
经过这几次提问之后我发现其中多个答案都在配置文件中提到了 variants 这项配置,于是:
1 | ryomahan [7:09 PM] |
马后炮:这个回答其实已经讲了很多有用的信息了。
我还是执着于让 AI 直接给我生成方案,所以我粗略看了一下之后又进行了如下提问:
<div class="bg-white">
等同于 <div class="light:bg-white">
.light
class 下 dark:bg-white 没有生效(这里我把我使用的配置文件贴上去了,因为内容太长就不复制过来了)bg-white
可以仅在 light mode 生效在 dark mode 不生效(又开了一个新的会话,这已经是第二天了,昨天晚上感觉这是个坑所以就去忙别的了)其实这一步的所有提问基本都是在做无用功,因为我太执着于直接获取解决方案而忽略了解决问题的正确流程,我把 AI 想象的过于强大了。
注:这一步我的诉求是让 AI 帮忙解释这一类功能的实现原理,在从原理引导 AI 帮忙生成解决方案。
经过了漫长的无效提问后我开始反思,我在处理问题的过程中太执着于让 AI 直接帮忙生成解决方案了,于是我又转换思路:先让 AI 帮忙阐述实现原理,再从原理入手生成解决方案。
1 | ryomahan [9:40 AM] |
马后炮:回过头来看,这个回答完全没有参考价值,甚至不如前面提到 variants 的那几个回答有价值,所以说 AI 的生成式回答的随机性还是比较坑的。
然后我问了:
这里我是想:在当前项目中所谓的主题模式是通过控制 Layout 根 DOM 的 class name 来实现的,当 class 是 dark 时为 dark mode,是 light 时为 light mode。所以只需匹配父类就能够实现我想要的东西。事实证明这个思路确实是没问题的,但是并不是通过简单的 class 实现的。
最终在 Tailwind CSS 的官方文档的引导下我发出了如下提问:
class="light:bg-white"
能够转换为如下 CSS .light .light:bg-white { background: #fff }
经过这四连问之后,我以为找到了解决这个问题的正确途径,因为这几个问题最终得到的结果虽然不能生效但是看上去像是那么回事,于是我整理了之前的问题继续发问:
1 | ryomahan [9:58 AM] |
这是 AI 给我的方案中距离正确答案最近的一次 ,但是仍旧无效,我又在这个问题的基础上尝试了十几轮的询问,得到的答案都是无效的。
在经历昨天下午两个小时外加今天上午两个小时的 AI 问询无果后,我悟了:就当从来没有 AI,早些时候我怎么解决问题现在还怎么解决,于是:
整个流程大概持续了十分钟左右的时间,我解决了这个问题,其实如果不去群里发求助信息一上来就看源码的话应该三两分钟就能解决问题。
完整搜索路径:先在 Tailwind CSS 中全局搜索 dark,看了一些结果发现都不是,好多都是测试用例或者其他无关文件中的。于是将搜索范围调整到 src 目录,大概视察了一下后定位在了一个叫 darkVariants 的变量上,跳转到指定文件后果然就是 dark mode 的定义原文,原文如下:
1 | darkVariants: ({ config, addVariant }) => { |
它被放在一个 variantPlugins 变量中,然后我通过引用查看发现使用该变量的地方是项目初始化内部 plugin,然后我参考这个思路定义了一个自定义插件:
1 | /** @type {import('tailwindcss').Config} */ |
问题解决。
经过这一次折磨人的使用经历后我觉得我需要重新审视一下 AI 在我日常编程时的定位,或者说我在编程时应该如何使用 AI。一下是基于本次经历我的几点感悟:
最近想为 SQLAlchemy 封装一套类似 Django ORM 的 Model Manager,于是捡起了「流畅的 Python」开始看被我遗留的「元编程」部分。在阅读的过程中,我慢慢发现自己并没有像想象的那样对 Python 类了如指掌,在很多概念的划分上我都是模棱两可的。因此特地总结这样一篇文章,希望能够由浅至深对 Python 类进行一次全面解剖手术。
注:本文所讨论的内容仅适用于 Python3。
注:本文针对源码的讨论仅适用于 CPython。
注:本文写作时参考 CPython 代码版本:3.9。
注:本文写作时参考 Python 官方文档版本:3.10.4。
在开始解剖 Python 类之前需要先正确的理解一些概念。
在 Python 中有一句影响深远的话:一切皆对象。请不要被误导,此处的「对象」指的是一个抽象的概念,而 Python 内部为了能够清楚的表达「对象」这个概念将其分为了两个部分:object and type,即对象和类型。由这两部分组成的能够表达某类抽象「对象」的东西在 Python 中被称为 class,即类。
注:后文中凡是有括号的「对象」表示的都是一个抽象概念,而无括号的对象表示的则是 Python 中的 object。
另外还有一组概念要提前理解:
class XX
的语法定义好某个类后,我们得到的只是一个类;注:关于这几个概念在本章的后续会有更详细的解释,而更深入的解析会在对应名字的章节上。
我们在使用 Python 语法编写程序时之所以能够一上来就定义一些复杂的类、生成复杂的类对象和实例对象,是因为 Python 在出厂时为我们包装好了各种基于类型和对象生成的类和相应类对象与实例对象(有些是内置的工具使用 C 实现的,拥有更好的执行效率)。
type。
Classes provide a means of bundling data and functionality together. Creating a new class creates a new type of object, allowing new instances of that type to be made. Each class instance can have attributes attached to it for maintaining its state. Class instances can also have methods (defined by its class) for modifying its state.
类把数据与功能绑定在一起。创建新类就是创建新的对象类型,从而创建该类型的新实例。类实例支持维持自身状态的属性,还支持(由类定义的)修改自身状态的方法。
注:可以仔细品读一下官方文档的这段话,这段话已经将类的绝大多数秘密展示出来了。
导入时:import 某个模块时此模块所处的状态;
运行时:调用某个模块时模块所处的状态;
注:在《流畅的 Python》这本书的 21.3 和 21.4 两个章节里有两个实例能够帮助更好地理解这两个概念。
类对象用于维护类的基本信息。
类对象支持两种操作:属性引用和实例化。
属性引用使用 Python 中所有属性引用所使用的标准语法:obj.name。有效的属性名称是类对象被创建时存在于类命名空间中的所有名称。
类的实例化使用函数表达法。可以把类对象视为是返回该类的一个新实例的不带参数的函数。
实例化操作(“调用”类对象)会创建一个空对象。 许多类喜欢创建带有特定初始状态的自定义实例。 为此类定义可能包含一个名为 __init__() 的特殊方法。
当一个类定义了 __init__() 方法时,类的实例化操作会自动为新创建的类实例发起调用 __init__()。
当然,__init__() 方法还可以有额外参数以实现更高灵活性。 在这种情况下,提供给类实例化运算符的参数将被传递给 __init__()。
实例对象用于维护实例的基本信息。
实例对象所能理解的唯一操作是属性引用。 有两种有效的属性名称:数据属性和方法。
数据属性 对应于 Smalltalk 中的“实例变量”,以及 C++ 中的“数据成员”。 数据属性不需要声明;像局部变量一样,它们将在第一次被赋值时产生。
另一类实例属性引用称为 方法。 方法是“从属于”对象的函数。在 Python 中,方法这个术语并不是类实例所特有的:其他对象也可以有方法。 例如,列表对象具有 append, insert, remove, sort 等方法。
注:请从此刻开始有意识的思考「实例对象的属性和类对象的属性之间的优先级关系」
注:有关类对象和实例对象的对比会在后文详细展开。
注:关于类对象和实例对象部分 网易云课堂-Python 微专业 的面向对象基础里有比较详细的讲解。
注:针对上一条,如果你搞不到资源的话可以看后面我自己的总结,内容大差不差。
实例,代表的是类对象与其实例对象或类型与对应类之间的关系。
type,即类型是实例关系的顶点,type 的类型还是 type,object 的类型也是 type。
继承,代表的是父对象与子对象或父类型与子类型之间的关系。
object,即对象是继承关系的顶点,object 没有更上一层的对象了,而 type 的父对象是 object。
注:关于实例和继承的详细过程也会在后文详展开。
变量,指的是在进行 Python 编码的过程中为某个具体对象赋予的名称。
属性也是变量,但属性不会单独出现。我们在称呼一个变量为属性的时候一般会称其为某某对象的属性。当然在一些常见场景中,为了方便称呼会省略定语「某某对象的」,但省略不代表没有。
在 Python 官方文档的 types 文档 中有这么几个类型:
1 | types.FunctionType |
大概可以理解为:
用户直接定义的函数或通过 lambda 生成的函数称为函数,是 FunctionType 类型,也就是函数;
用户通过实例引用的函数称为方法,是 MethodType 类型,也就是方法;
所有内建的函数(方法)称为内建函数(方法),是 BuiltinFunctionType,也就是内建函数(方法);
其中 FunctionType 和 LambdaType 是等效的,BuiltinFunctionType 和 BuiltinMethodType 是等效的。
注:关于函数和方法的详细区别,以及类中函数转化为方法的过程会在后文展开。
可以通过函数调用的方式(例如 foo()
)进行调用的对象。
我刚开始接触 Python 时看的各种学习资料都在强调一点:在 Python 中,一切皆是对象。直到我整理这篇文章之前我对这句话的理解都仅限于字面意思。最近,正好借着工作需要的由头我收集了一些相关的资料(比较重要的我都放在了 参考 中)横向地了解了一下。下面就正式开始,好好地扒一扒 Python 中的「一切接对象」到底是什么意思以及隐藏在这句话背后的 Python 类究竟是如何构造的。
从 Python 官方文档来看,object 关键字对应的是一个可调用对象,下面是官方文档对 object 的描述:
Return a new featureless object. object is a base for all classes. It has methods that are common to all instances of Python classes. This function does not accept any arguments.
object does not have a __dict__, so you can’t assign arbitrary attributes to an instance of the object class.返回一个不带特征的新对象。object 是所有类的基类。它带有所有 Python 类实例均通用的方法。本函数不接受任何参数。
由于 object 没有 __dict__,因此无法将任意属性赋给 object 的实例。
注:这里有个细节可以关注一下,虽然 object 被放在内置函数章节,但是却被标记为了 class。
Cpython 中 Python 所有类型都由结构体 PyObject
扩展而来,而那些指向可变大小 Python 对象的指针都可以被转换为 PyVarObject
,具体如下:
1 | // 注:为了阅读体验,在书写格式上有所调整 |
PyObject 和 PyVarObject 一般是作为头部被包含在一个变量结构体中的(可以近似理解为继承),根据该变量大小是否固定来选择使用哪一种。PyObject 和 PyVarObject 是 Cpython 的基石,Python 中的 object 和 type 都是由这两个结构体拓展而来。
1 | // 注:为了阅读体验,在书写格式上有所调整 |
注:本人没有系统性的学习过 C/C++ 所以在后文的表述中难免有些外行。
仅从当前展示的代码还比较难理解 CPython 中 Python 类的实现思路,需配合后文中类型的 CPython 源码分析才能更全面的理解 Python 中那些底层的特性是为何表现出来的。
从代码中不难发现 PyBaseObject_Type 是结构体 PyTypeObject 的一个实现。而且结构体的第一行 PyVarObject_HEAD_INIT(&PyType_Type, 0)
也印证了 type(object) == type
(PyType_Type 是 type 的实现)。
从源码中可以找到,tp_base 对应的值是 0。
最开始我是这么认为的,但后面当我想用同样的道理证明 tpye.__base__ == object
的时候却发现 PyType_Type 的 tp_base 也是 0。后面我在 古明地觉:《源码探秘 CPython》3. type 和 object 的恩怨纠葛 一文中看到了半句解释:
因为 Python 的动态性,显然不可能在定义的时候就将所有成员属性都设置好、然后解释器一启动就会得到我们平时使用的类型对象。
目前看到的类型对象是一个半成品,有一部分成员属性是在解释器启动之后再进行动态完善的。
我简单的翻看了一下后续的文章没有找到实际完善的地方,后续找到解释再进行补充吧。
1 | dir(object) |
1 | object.a = 1 |
从 Python 官方文档来看,type 关键字和 object 关键字一样对应的是一个可调用对象,下面是官方文档对 type 的描述:
With one argument, return the type of an object. The return value is a type object and generally the same object as returned by object.class.
The isinstance() built-in function is recommended for testing the type of an object, because it takes subclasses into account.
With three arguments, return a new type object. This is essentially a dynamic form of the class statement. The name string is the class name and becomes the name attribute. The bases tuple contains the base classes and becomes the bases attribute; if empty, object, the ultimate base of all classes, is added. The dict dictionary contains attribute and method definitions for the class body; it may be copied or wrapped before becoming the dict attribute.
传入一个参数时,返回 object 的类型。 返回值是一个 type 对象,通常与 object.class 所返回的对象相同。
推荐使用 isinstance() 内置函数来检测对象的类型,因为它会考虑子类的情况。
传入三个参数时,返回一个新的 type 对象。 这在本质上是 class 语句的一种动态形式,name 字符串即类名并会成为 name 属性;bases 元组包含基类并会成为 bases 属性;如果为空则会添加所有类的终极基类 object。 dict 字典包含类主体的属性和方法定义;它在成为 dict 属性之前可能会被拷贝或包装。
1 | // 注:为了阅读体验,在书写格式上有所调整 |
通过分析 PyTypeObject 我们可以发现一些 Python 底层有趣的实现:
在 PyTypeObject 中有这样几个值
PyAsyncMethods *tp_as_async
PyNumberMethods *tp_as_number;
PySequenceMethods *tp_as_sequence;
PyMappingMethods *tp_as_mapping;
PyBufferProcs *tp_as_buffer;
这些值的类型分别对应到了同名的结构体上,而这些结构体中定义了相关类型的操作方法,正是由于这样的结构使得 Python 可以支持鸭子类型。
注:这里描述的并非鸭子类型的原理,仅仅是通过源码进行一些延伸。
除此之外,如果想了解更多 PyTypeObject 内部变量的具体意义可以访问 Python 官方文档-C API-Type Objects 进行查看。
1 | PyTypeObject PyType_Type = { |
从源码中可以发现 PyType_Type 也是结构体 PyTypeObject 的实现。同样结构体的第一行 PyVarObject_HEAD_INIT(&PyType_Type, 0)
印证了 type(type) == type
。
甚至:type(type(type)) == type,无论嵌套多少层都是成立的。
原因待补充。
1 | dir(type) |
至此,CPython 中用于实现 Python 类的几个比较重要的结构体和实现都被展示出来了:
后文基本上都是基于 Python 的内容了,不会再设计 CPython 源码的列举。
根据前文的内容我们已然得知:类是由类型实例化得到的。
1 | class Foo: |
通过上面的代码我们能得到两个类对象:Foo 和 Bar,其中 Foo 的定义方式是在 Python 编码过程中常用的形式,而 Bar 的定义方式则更能体现「类是有类型实例化得到的」。
1 | class Foo(metaclass=type): |
当有很多属性和方法需要定义时,使用 Bar 的定义方式会显得很不方便。但在通过对 Foo 的定义方式进行改造后,同样能够帮助我们清楚地看清类对象生成过程。
1 | class FooType(type): |
为了更清楚的理解类型(元类)的工作过程,我封装了一个 FooType 类型,并让 Foo 类对象使用这个类型。
当我们使用编辑器在文件中定义好类的主体后,就可以使用 Python 解释器加载相关文件(模块)了,在 Python 解释器加载了相关文件(模块)后,定义好的类主体会被用来生成相应的类对象。
1 | # test.py |
在 Python3 中,类的继承解析使用的是 C3 算法,可以参考我的另一篇文章:Python MRO。
1 | class Foo: |
当我们在编码的过程中定义了类实例化的内容,并在导入相关文件(模块)后执行相关代码,Python 解释器会在执行到类实例化内容的时候生成实例对象。
1 | class Foo: |
注:类对象也是可以调用实例方法的,只是需要传入一个存在的实例对象作为 self;
1 | class Foo: |
以上,方法在无形中将 self 传入到了函数中。
1 | class Foo(Bar): |
Python 之所以要设计这么复杂的一套逻辑来构建「类」体系,其核心目的就是为了帮助使用者在使用的过程中能够更加方便的进行概念抽象化,并且能够趁手的使用被抽象化后的类。
关于抽象,我最早产生好奇的时候搜到了 CSDN 杏仁技术栈转载的章烨明的 谈谈到底什么是抽象,以及软件设计的抽象原则 以及 optman 的 抽象的层次,此外应该还看过其他的一些文章但是我没有保存下来所以也没法考究了。在那之后,我大致能在编程的过程中理解抽象以及那些被抽象的东西,并且能够沿着抽象层级一点一点的去拨开各类开源库的核心,从中学习好的编码思想。
现在,我对「抽象」这个概念又产生了新的困惑,我开始好奇原本抽象的上一层抽象是什么,我还好奇数学是被发现的还是被发明的。通过最近这段时间的阅读我已经有了笼统的概念,下面是我的一些浅显的理解,希望能够帮助同样好奇的你。
1 | # 在编程过程中,下面的概念自左向右抽象等级依次提高 |
注:以下歪解大都是以「更好的理解 Python」为出发点的。
道生一,一生二,二生三,三生万物。
——《道德经》
人们将自己对于现实世界的观察(道)进行抽象产生了对象(一)这个概念,再以对象为基础创建了一门编程语言,这门编程语言中万事万物都是对象,并通过对象和类型两个概念(二)去描述所有的对象(万物)。
当然,人们对世界的观察并非真正的「道」而是人们对「道」的「名」。但放到一门编程语言中,这些「名」组成了这门语言的「道」。
“一”可以是阴,也可以是阳。但是只能是一个面。“一”有它的对立面,这就有了阴阳。“二”就是阴阳。阴阳是有力量的, 他们互不相容,必定要相互作用,这种相互作用的趋势,就是“三”,就是冲气。道生一,一生二,二生三,把“生”字换成“有”更好理解。道有一,也有零;一有它的对立面,这就构成了二;二有三,因为二中的两个一相互对立,所以要相互作用,这个趋势就是三。“三生万物”的“生”理解为“产生”更合适。
——《道德经》 富强 译注
参照注释的讲解,可以对我上面的描述进行更进一步的理解:在开始创建一门面向对象的编程语言的时候,这门语言中还没有任何概念,因此我们从现实生活中进行抽象,得到了「对象」。仅有「对象」这个概念是无法开展下一步的工作的,因此我们尝试对「对象」进行描述得到了「类型」,在有了「类型」后「对象」和「类型」就有了一种相互作用的能力:「(反)实例化」。
在有了这样的一门编程语言之后,我们就可以利用面向对象的特性和抽象思想将现实生活中的业务场景使用编程语言表达出来。
本来是想好好的剖析一下 Python 中的类,结果写出这么一篇四不像来。在写作的过程中总是想涵盖更多的东西,结果发现自己好像 hold 不住,于是开始删减,只保留了当前的这些内容,希望能够帮助你更好的理解 Python 中的类。
本文的主体内容大部分来自对 PEP 343 原文的翻译,其余部分为本人对原文的理解,在整理过程中我没有刻意地区分翻译的部分和我个人理解的部分,这两部分内容被糅杂在一起形成了本文。因此,请不要带着「本文的内容是百分之百正确」的想法阅读。如果文中的某些内容让你产生疑惑,你可以给我留言与我讨论或者对比 PEP 343 的原文加以确认。
本 PEP 为 Python 增加了一个新的语法:with,它能够更加简便的代替标准的 try/finally 语法。
在本 PEP 中,上下文管理器 提供了 __enter__() 和 __exit__() 方法,分别在进入和退出 with 语句时被调用。
本 PEP 最初是由 Guido 以第一人称撰写,随后由 Nick Coghlan 更新那些后来在 python-dev 上的讨论。
在对 PEP 340 及其代替方案进行大量讨论后,Guido 决定撤销 PEP 340 并在 PEP 310 的基础上提出一个新的版本。此版本增加了一个 throw() 方法用于在 暂停的生成器 中引发异常以及一个 close() 方法用来抛出 GeneratorExit 异常,并将语法关键字修改为 with(在 PEP 340 中定义的是 block)。
在 PEP 343 被正式通过后,以下 PEP 由于内容重叠被驳回或撤销:
在 Python Wiki 上对本 PEP 的早期版本进行过一些讨论。
PEP 340 中阐述了大量优秀的想法,例如:使用生成器作为 block 语法的模板,添加异常处理和 finalization(个人感觉可以理解为析构方法) 给生成器等等。除了赞同,它也因为底层是循环结构的事实遭到了很多人的反对。使用循环结构意味着 block 语法体内的 break 和 continue 会扰乱正常的代码逻辑带来不确定性,即使它被用来作为一个非循环资源的管理工具。
Raymond Chen 的 一篇对流程控制宏提出反对的文章 让 Guido 做出了最终决定——放弃 PEP 340。Raymond 的文章中有一个观点:将流程控制隐藏在宏中会让代码变得更难让人读懂也更难维护,Guido 发现这个观点在 Python 和 C 都适用,并由此意识到 PEP 340 提出的 templates 会隐藏几乎所有控制流程。例如,PEP 340 的 examples 4 中的 auto_retry 函数只能捕获异常并且重复 block 最多三次(而这一点被隐藏在了语法的内部)。
对比来看 PEP 310 的 with 语法并没有隐藏流程控制,虽然 finally 会暂时地中止控制流,但在最后(finally 内的内容执行完毕后)控制流会继续执行就好像 finally 不存在一样。
PEP 310 中粗略地提出了这样的语法(其中 VAR 是可选的):
1 | with VAR = EXPR: |
上面的语法大致上可以翻译成这样:
1 | VAR = EXPR |
但当在 PEP 310 的语法基础上实现如下代码时:
1 | with f = open("/ect/passwd"): |
按照前面给的示例这段代码大致可以被翻译成这样:
1 | f = open("/etc/passwd") |
上面的代码好像默认了 BLOCK1 的部分会没有异常地顺利的执行,然后 BLOCK2 部分会被紧接着调用。但如果在 BOLCK1 中有异常抛出或执行了一个 non-local goto(例如:break, continue, return),BLOCK2 就无法被顺利执行。with 语法在尾部增加的魔法(__exit__())并不能解决这一问题。
你也许会问:如果在 __exit__() 方法中抛出了一个异常会怎样?
如果真的发生了,那一切就都完了,但这并不会比其他情况下引发的异常更糟糕。
异常的本质就是它可以在代码的任意位置被抛出,而你只能忍受这一点。即使你写的代码不会抛出任何异常,一个 KeyboardInterrupt 异常仍然会导致它在任意两个虚拟机器操作码之间退出(个人理解:哪怕你的程序没有任何问题,正在正常执行的程序也可能因为你的强制退出行为而退出,例如常见的 Ctrl + c
)。
上面的这些讨论以及 PEP 310 表现出来的特性让 Guido 开始更倾向于使用 PEP 310 的语法,但是他还是希望能够实现 PEP 340 中提出的:使用生成器作为获取和释放锁或打开和关闭文件等抽象概念的「模板」。这是一个很有用的功能,通过 PEP 340 的示例 就能够有所了解。
受 Phillip Eby 对 PEP 340 一个反对建议的启发,Guido 尝试创建一个装饰器,将一个合适的生成器变成一个具有必要的 __enter__() 和 __exit__() 方法的对象。但在实现过程中他遇到一个障碍:处理 locking example 并不困难,但是处理 opening example 几乎是不可能的。他是这样定义语法模板的:
1 |
|
并且可以被这样使用:
1 | with f = opening(filename): |
问题是在 PEP 310 中,EXPR 表达式的结果会被直接分配给 VAR,然后在退出 BLOCK1 时调用 VAR 的 __exit__() 方法。但在这里,VAR 显然需要得到被打开的文件对象,这就意味着 __exit__() 必须是文件对象上的一个方法。
虽然这个问题可以通过代理类来解决,但这种解决方案并不优雅。在思考之后,Guido 意识到:只需要稍微改动语法模板就可以轻松地编写出所需的装饰器。这个改动就是:将 VAR 设置为 __enter__() 方法的调用结果,并且保存 EXPR 的值以便后面调用它的 __exit__() 方法。此时装饰器就能返回一个 wrapper 类的实例,实例的 __enter__() 方法调用生成器的 next() 方法返回 next() 所返回的内容。wrapper 类实例的 __exit__() 方法会再次调用生成器的 next() 方法并(期望)抛出一个 Stoplteration 异常。详情可以看下面的 生成器装饰器一节。
所以现在最后的障碍是 PEP 310 的语法:
1 | with VAR = EXPR: |
不应该使用赋值操作而应该使用隐式操作,因为本质上并不是将 EXPR 赋值给 VAR。参考 PEP 340 的实现,可以修改为:
1 | with EXPR as VAR: |
在提案的讨论中还能够看出,开发者们普遍希望能够在生成器中捕获到异常,即使仅是拿来做日志。但生成器是不允许 yield 其他值的,因为 with 语法不应该被设计成一个循环(抛出一个不同的异常是稍微能够被接受的)。
为了实现这个需求,一个新的生成器方法 throw() 被提出,它将接收一到三个代表异常的可选参数(type, value, traceback)并且在 生成器暂停时 抛出它。
在提出 throw() 方法后另一个生成器方法 close() 也自然而然地被提出了。close() 方法能够通过一个名为 GeneratorExit 的特殊异常调用 throw() 方法。这个行为相当于告诉生成器准备退出,并且在此基础上还有一个小小的改进,即在生成器被垃圾回收机制回收时会自动触发 close() 方法。
自此以后,我们就可以在 try-finally 语法中插入 yield 语法了,因为我们现在能够保证 finally 语句最后一定会被执行。但还有一些关于 finalization apply 的常见警告:进程可能在没有析构任何对象的情况下突然终止,而且对象可能会因为应用中的循环或内存泄漏而永远存在(与被 GC 控制 的 Python 的循环或内存泄漏相反)。
注意,我们并不能保证 finally 部分会在生成器对象无引用后被立即执行,尽管 CPython 中是这样实现的。这与自动关闭文件类似:虽然在 CPython 中对象的最后一个引用消失后会立即删除该对象,但其他的 GC 算法未必做了同样的实现。
可以去 PEP 342 中找那些生成器被修改的细节。
一个新的声明被提出,它的语法是:
1 | with EXPR as VAR: |
其中 with 和 as 是新的关键字。EXPR 是一个任意的表达式(但不能是一个表达式列表),VAR 是一个单一的分配目标,它不能是逗号分割的变量序列,但可以被括号包裹的逗号分割的变量序列(这个限制使得未来的语法有可能拓展为多个逗号分割的资源,每个资源都有对应的 as 语句)。
as VAR
部分是可选的,上述声明被翻译为:
1 | mgr = (EXPR) |
其中小写的变量(mgr, exit, value, exc)都是内部变量,用户不能访问,它们大概率会被实现为特殊的寄存器或 stack positions。sys.exc_info 的详细信息可以参考 sys.exc_info 章节
上述代码翻译细节是为了规定准确的语义。如果相关方法没有像预期那样被找到,解释器将会抛出 AttributeError 异常。同样,如果任何一个调用抛出了一个异常,其效果与上述代码一致。最后,如果 BLOCK 包含 non-local goto 语句,那么 __exit__() 方法会像 BLOCK 被正常执行完一样被调用,并带有三个 None 参数。也就是说,这些「伪异常」不会被 __exit__() 视为异常。
如果 as VAR
部分的语法被省略,翻译中 VAR =
的部分也会被省略,但 mgr.__enter()
仍被调用。
mgr.__exit__() 的调用惯例如下:
重要的是:如果 mgr.__exit__() 返回的是 True,异常就会被忽略。也就是说,如果 mgr.__exit__() 返回 True,那么在 with 语句之后的下一个语句仍会被执行,即使 with 语法内部发生了异常。
然而,如果 with 语法被某个 non-local goto 中断,当 mgr.__exit__() 返回时,这个 non-local return 将在不考虑返回值的情况下被恢复。这样做的目的是使 mgr.__exit__() 能够在不被频繁误触发的情况下实现忽略异常抛出(因为 mgr.__exit__() 的默认返回值是 None 相当于 Flase,能够使异常被重新抛出)。
实现忽略异常抛出功能的主要目的是使编写 @contextmanger 装饰器成为可能。在能够忽略异常抛出的情况下实现的 @contextmanger 装饰器能够使一个被装饰的生成器内部的 try/except 块的行为就像生成器的主体在 with 语法的位置上被在线拓展一样。
将异常细节传递给 __exit__() 的动机,与 PEP 310 中无参数的 __exit__() 一致,具体可以参考示例章节的 transaction:数据库事务管理。在本示例中,生成器函数必须根据是否发生异常来决定是否进行事物回滚。在这种情况下,传递完整异常信息要比使用一个布尔值来标记是否发生异常更具有可拓展性,例如为异常记录工具提供接口。
依靠 sys.exc_info() 来获取异常信息的做法被否定了,因为 sys.exc_info() 的语义非常复杂,它完全有可能返回一个很久以前就被捕获的异常信息。还有人提议增加一个布尔值来区分顺利执行完 BLOCK 块和 BLOCK 块被 non-local goto 中断。这个提议也被否定了,因为它太过复杂且没有必要。对于数据库事务回滚的决定来说,non-local goto 应该被视为异常。
为了使直接操作上下文管理器的 Python 代码的上线文变得简单,__exit__() 方法不应该重新抛出传递给它们的异常。应该总是由 __exit__() 方法的调用者负责决定何时重新引发异常。
因此,调用者可以通过是否抛出异常来区分 __exit__() 是否执行失败:
因此,在实现 __exit__() 方法时应该避免抛出异常,除非真的存在异常(不需要避免抛出那些被传入的异常)。
在 PEP 342 通过后,我们就可以通过编写一个装饰器,让被此装饰器装饰的生成器可能被 with 语法使用,且此生成器刚好 yeild 一次。下面是完成此类装饰器的代码:
1 | class GeneratorContextManager(object): |
这个装饰器可以被这样使用:
1 |
|
对此装饰器更加完善的实现已经成为 标准库的一部分。
可以给文件、套接字和锁等等对象添加 __enter__() 和 __exit__() 方法,这样我们就可以像下面这样操作这些对象:
1 | with locking(myLock): |
也可以简写成
1 | with myLock: |
但是使用过程中应该谨慎,它可能会导致类似的错误:
1 | f = open(filename) |
上面的代码并不会像人们想象的那样顺利的执行,因为 f 在进入 BLCOK2 之前被就已经被关闭了。
还有很多类似上面示例中的错误,例如:
对于 Python 2.5,以下类型已经被确定为上下文管理器:
1 | - file |
一个上下文管理器也将被添加到 decimal 模块中,以支持在 with 语句中使用本地 decimal arithmetic 上下文,当 with 语句退出时自动回复原始上下文。
本 PEP 提议将由 __enter__() 和 __exit__() 方法组成的协议称为「上下文管理协议」,而实现该协议的对象被称为「上下文管理器」。
语句中紧跟 with 关键字的表达式是一个「上下文表达式」。
with 语句主体中的代码和 as 关键字后面的变量名(或名称)没有特殊的术语。可以使用一般的术语「statement body」和「target list」,如果这些术语不清楚的话,可以用 with 或 with statement 作为前缀。
鉴于 decimal 模块的算术上下文等对象的存在,使得单独使用「上下文」这个术语可能存在歧义。如果有必要,可以用「上下文管理器」来表示由上下文表达式创建的具体对象,用「运行时上下文」或(最好是)「运行时环境」来表示由上下文管理器进行的实际状态修改,从而使描述更加具体。
当简单地讨论 with 语句的使用时,由于上下文表达式已经全面描述了对运行时环境所做的修改,因此此时这种模糊性不应该太重要。但在讨论 with 语句本身的机制以及如何实际实现上下文管理器时,这些名词之间的区别就尤为重要。
许多上下文管理器(如文件和基于生成器的上下文)都是一次性使用的对象。一旦 __exit__() 方法被调用,上下文管理器将不可复用 (例如,文件已被关闭,或者底层生成器已执行完毕)。
为每个 with 语句创建一个新的上下文管理器对象是避免多线程代码或嵌套的 with 语句试图使用同一个上下文管理器问题的最简单的解决方案。标准库中所有可复用的上下文管理器都来自 threading 模块,它们都针对线程和嵌套使用所产生的问题进行过相应设计。
这意味着,为了在多个 with 语句中重复使用一个带有特定初始化参数的上下文管理器,通常需要将其存储在一个零参数的可调用对象中,然后在每个语句的上下文表达式中调用,而不是直接缓存上下文管理器。
当这种限制不适用时,受影响的上下文管理器的文档应该明确说明这一点。
几个月来,PEP 都为了避免隐藏控制流程而禁止忽略异常抛出,但在实施过程中发现这是一个很难避免的麻烦,因此 Guido 重启了这个功能。
本 PEP 的另一个核心是提出了一个 __context__() 方法,类似于 iterable’s __iter__() 方法。这引起了无休止的问题和术语讨论。不断解释 __content__() 方法相关问题的过程中产生的新问题让 Guido 最终彻底删除了这个概念。
直接使用 PEP 342 的生成器 API 来定义 with 语法 也被简短地讨论过,但很快就被否定了,因为如果这么做会使得编写非生成器的上下文管理器程序变得异常困难。
基于生成器的例子依赖于 PEP 342。另外,有些例子在实践中是不必要的,因为现有的对象,如 threading.RLock,能够直接用于 with 语句中。
示例中上下文的名称所使用的时态不是任意的:
在语句开始时获得锁,离开时释放锁:
1 |
|
可以这样使用:
1 | with locked(myLock): |
在语句开始时通过特定模式打开文件,离开时关闭文件:
1 |
|
可以这样使用:
1 | with opened("/etc/passwd") as f: |
提交或回滚数据库事务:
1 |
|
在不使用生成器情况下重写 例 1:
1 | class locked: |
这个例子很容易修改成其他相对无状态的例子,这表明,如果不需要保留特殊状态,很容易避免对生成器的依赖。
暂时重定向 stdout:
1 |
|
可以这样使用:
1 | with opened(filename, "w") as f: |
这样实现不能保证线程安全,但如果手动实现相同的动作也无法保证线程安全。在单线程程序中(例如,在脚本中),这是一种流行的处理方案。
opened() 的一个变体,能够同时返回文件句柄和异常内容:
1 |
|
可以这样使用:
1 | # 在 as 后使用元组接收返回值 |
另一个有用的例子是阻断信号的操作,实现代码如下:
1 | import signal |
可以传入一个要屏蔽的信号列表,默认情况下,会屏蔽所有的信号。
decimal 上下文,这里有一个简单的例子:
1 | import decimal |
使用示例(改编自 Python Library Reference):
1 | def sin(x): |
一个简单的 decimal 模块的上下文管理器:
1 |
|
使用案例:
1 | from decimal import localcontext, ExtendedContext |
通用「object-closing」上下文管理器:
1 | class closing(object): |
可以用来确切地关闭任何有 close 方法的对象,无论是文件、生成器还是其他东西。它也可以在不确定对象是否需要关闭时使用(例如,一个接受任意 iterable 的函数):
1 | # emulate opening(): |
Python 2.5 的 contextlib 模块包含 这个上下文管理器的实现(3.x 版本也保留了)。
PEP 319 给出了一个用例,即使用 release 上下文来暂时释放先前获得的锁。
可以通过交换 acquisition() 和 release() 的调用顺序,实现上面的 locked:锁管理 。
实现代码如下:
1 | class released: |
使用案例:
1 | with my_lock: |
一个「嵌套」的上下文管理器,它能自动地将提供的上下文从左到右嵌套,以避免过度缩进:
1 |
|
使用案例
1 | with nested(a, b, c) as (x, y, z): |
等同于:
1 | with a as x: |
Python 2.5的 contextlib 模块包含这个上下文管理器的实现(没有在 3.x 的文档中找到同名方法)。
本 PEP 最初是由 Guido 在 2005.06.27 的 EuroPython 主题演讲中接受的。后来它被再次接受,并加入了 __context__ 方法。
本 PEP 是在 Python 2.5a1 的 Subversion 中实现的,在 Python 2.5b1 中删除了 __context__()方法。
可以通过 inspect.getgeneratorstate(generator)
查看生成器状态:
GEN_CREATED: Waiting to start execution.
GEN_RUNNING: Currently being executed by the interpreter.
GEN_SUSPENDED: Currently suspended at a yield expression.
GEN_CLOSED: Execution has completed.
本函数返回的元组包含三个值,它们给出当前正在处理的异常的信息。返回的信息仅限于当前线程和当前堆栈帧。如果当前堆栈帧没有正在处理的异常,则信息将从下级被调用的堆栈帧或上级调用者等位置获取,依此类推,直到找到正在处理异常的堆栈帧为止。此处的「处理异常」指的是「执行 except 子句」。任何堆栈帧都只能访问当前正在处理的异常的信息。
如果整个堆栈都没有正在处理的异常,则返回包含三个 None 值的元组。否则返回值为 (type, value, traceback)。它们的含义是:
本文的主体内容大部分来自对 ASGI Documentation 原文的翻译,其余部分为本人对原文的理解,在整理过程中我没有刻意地区分翻译的部分和我个人理解的部分,这两部分内容被糅杂在一起形成了本文。因此,请不要带着「本文的内容是百分之百正确」的想法阅读。如果文中的某些内容让你产生疑惑,你可以给我留言与我讨论或者对比 ASGI Documentation 的原文加以确认。
本人在之前整理过一篇 何为 WSGI 是对 PEP 333 和 PEP 3333 的翻译和整理,感兴趣的话可以结合起来一起阅读。
注:仅代表本人理解,在涉及到这些单词的地方均使用英文单词。
英文 | 中文 | 解释 |
---|---|---|
ASGI | 异步服务器网关接口 | |
WSGI | Web 服务器网关接口 | |
Server | 服务器 | Web 软件中面向 Client 提供具体服务的部分 |
Application | 应用(应用框架) | Web 软件中面向 Server 提供具体应用服务的部分 |
Connection | 连接 | 一个满足某种协议的 Socket 连接 |
Event | 事件 | 连接中发生的事件的抽象 |
scope | \ | 存放连接细节的容器 |
send | 发送器 | 应用发送事件消息的工具 |
receive | 接收器 | 应用接受事件消息的工具 |
ASGI (Asynchronous Server Gateway Interface) is a spiritual successor to WSGI, intended to provide a standard interface between async-capable Python web servers, frameworks, and applications.
ASGI(异步服务器网关接口)是 WSGI 的精神续作,目的是为具有异步功能的 Python Web 服务器、框架和应用之间提供一个标准接口。
from ASGI Documentation
一个 ASGI Application 是一个单一异步可调用对象,它包含三个参数:
在这种结构下,ASGI 下的每个 Application 不仅能同时处理多个 incoming Event 和 outcoming Event,在协程的加持下 Application 还可以做更多的事情,例如监听像 Redis 队列一样的外部的 trigger Event。
下面是一个最简单的 ASGI Application 模型:
1 | async def application(scope, receive, send): |
所有的 Event 都是通过一个特定格式的 Python 字典被 send 和 receive 进行处理,正是这些 Event formats 构成了 ASGI 标准的基础,使得 Application 能够适应不同的 Servers。
所有的 Event formats 都有一个用来决定自身结构的 type 键值对,下面就是一个 ASGI 中 HTTP 协议发送请求的 Event format 示例:
1 | { |
下面则是一个 ASGI 中通过 WebSocket 协议发送消息的 Event format:
1 | { |
ASGI 被设计成 WSGI 的超集,并且给出了一个两者之间的转换方式,允许 WSGI Application 通过一个 translation wrapper 在 ASGI Server 中运行。
一个线程池可以在 async event loop 之外运行同步的 WSGI Applications。
注:这里的原话是 A threadpool can be used to run the synchronous WSGI applications away from the async event loop.
,不太清楚是想表示一种并列还是异或关系。
本规范提出了一种 Network Protocol Servers(尤其是 Web Servers) 和 Python Applications 之间通讯的标准接口,旨在允许通过一套协议同时处理多种常见协议,例如:HTTP、HTTP/2、和 WebSocket。
本规范期望通过固定一套 API,满足 Server 与 Server 之间和 Server 与 Application 之间的交互需要。每一个被支持的协议都有一个子规范,子规范描述了如何将该协议编码或解码为 Event 消息。
WSGI 规范自推出以来一直运行良好,它的出现使得 Python Framework 和 Web Server 之间的对接变得更加灵活和简便。然而,它的设计过分依赖于 HTTP 风格的 Request/Response 循环,但现在越来越多不遵循这种交互模式的协议正在网络编程时被普遍使用,其中最明显的就是 WebSocket。
ASGI 便在这种情况下应运而生,它试图维护一个简单 Application API,同时提供一套抽象方法,允许来自不同 Appliction 线程或进程的数据在任何时刻被发送和接受。
基于「将不同协议转换为 Python 兼容且异步友好的消息集」的原则,ASGI 可以概括为两部分:
ASGI 的核心目的是提供一种方法在能够处理 HTTP/2 和 WebSocket 协议的同时正常处理 HTTP 协议。然而要实现这个功能就必然意味着需要一个简单快捷的方式来支持现有的 WSGI Servers 和 Applications。因为目前大部分的 Python Web 服务都依赖于 WSGI。这部分内容被放在了 HTTP 子协议。
ASGI 由两个不同的组件组成:
与 WSGI 相同的是,Server 会在内部处理 Application 并以标准化的格式向其分派请求。不同的是,ASGI 中的 Application 是异步的可调用对象,而不是简单的可调用对象,它通过接收和发送异步事件与 Server 进行通信,而不是接收单一的输入流并返回单一的可迭代对象。
ASGI Applications 必须以 async/await 兼容的协程程序运行,及兼容 asyncio。如果需要使用同步代码可以在主线程自由的使用线程或其他进程。
与 WSGI 的另一个不同之处是,ASGI 的 connection 有两个独立的部分:
Application 通过一个 Connection Spoce 和两个异步的可调用对象被调用的阻塞(await)来接收和返回事件消息。所有的这些都发生在异步事件循环(async event loop)中。
每一次对 Application 可调用对象的调用都会映射到一个传入的 socket 或 Connection,并会延续该 Connection 的寿命,如果有 cleanup 则 Connection 会持续更长时间。
一些协议可能不使用传统的 sockets,ASGI 在规定这些协议时会定义 Scope 合适过期以及合适关闭。
用户对 ASGI Application 的每个请求都会对应一个 Connection 并引发对 Application 可调用对象的调用,来完整地处理此 Connection。对特定 Connection 信息的描述和生命周期的记录被称为 Connection Scope。
第一个被传入 Application 可调用对象的参数就是一个存放特定 Connection 信息的 Scope 字典。
例如,在 HTTP 协议下,Connection Scope 仅持续一个请求,但是这个被传递的 Scope 包含了大多数钱请求数据(除了 HTTP Request Body,因为这部分内容是通过事件流传输的)。
但是,在 WebSocket 协议下,只要 socket 被接通 Connection Scope 就会维持下去。并且 Scope 会传递包含 WebSocket path 在内的信息,不过像消息等细节内容则是作为 Events 传递的。
一些协议可能会给定一个信息非常有限的 Scope,因为它们封装了类似握手之类的内容。但是每个协议的定义都必须包含 Connection Spoce 的持续时间和你将在这个 Scope 参数中获取那些信息。
根据协议细节不同,Applications 在与 Client 通讯之前可能需要等待一个初始的启动信息。
ASGI 将协议分解为一系列 Application 必须接收和反应的的 Events,以及 Application 可能在响应中发送的 Events。对于 HTTP 来说,就是简单的按顺序接收两个 Events:http.request
和 http.disconnect
并且发送相应的 Event 消息。而对于像 WebSocket 之类的协议,它可能更多的会是:先接收一个 websocket.connect
,再发送一个 websocket.send
,在接收一个 websocket.receive
最后接收一个 websocket.disconnect
。
每个 Event 都是一个带有 top-level type key 的字典,它包含一个关于消息类型的 Unicode 字符串。用户可以自由的创造属于他们自己的消息类型并且在高级 Events Applications 实例之间发送它们,例如:一个了解 Application 可能会通过一个 mychat.message
的 user tpye 发送聊天信息。Applications 应该能够处理一个关于 Events 的混合集合,它们一些来自 Incoming Client Connection,一些来自 Application 的其他部分。
Events 中的信息可以通过网络发送,因此,它们需要被序列化,所以只能以以下类型进行传播:
在 3.0 版本中,Application 格式改变为使用一个单一的可调用对象,而不是之前的双可调用对象。双可调用的写法在后面的 [Legacy Application](#Legacy Application) 中有所记载。服务器可以使用 asgiref.compatibility
库轻松地实现对它的支持,并且应该尽可能支持它。
ASGI Application 应该是一个单一的异步可调用对象:
1 | coroutine application(scope, receive, send) |
Application 会在每个 Connection 中被调用一次。Conection 的定义以及其生命周期由协议规范决定。例如,对于 HTTP 来说一个 Connection 就是一次请求,而对于 WebSocket 来说一个 Connection 是一个 WebSocket 连接。
你发送和接收的 Scope 和 Event 消息的格式都是由 Application 协议之一定义的。Scope 必须是一个字典。scope["type"]
必然存在,可以用它来判断那个协议被传入。scope["asgi"]
也会以字典的形式存在,其中 scope["asgi"]["version"]
代表了 Server 支持的 ASGI 的版本。如果这个值不存在,则默认为 "2.0"
。
也可能有一些特殊的版本信息存放在 scope["asgi"]["spec_version"]
。这样做能够允许各个协议规范进行增强而不影响整个 ASGI 版本。
在本规范的具体协议子规范中给出了详细的 Scope 和 Event 消息的格式,它们就类似于 WSGI 的 environ 字典中的 keys 的规范。
ASGI v2.0 Application 被定义成一个可调用对象:
1 | application(scope) |
它能够返回另一个异步的可调用对象:
1 | coroutine application_instance(receive, send) |
其中 scope, receive 和 send 的含义与新版本一致,但注意:第一个可调用对象是同步的。
第一个可调用对象会在 Connection 开始时被调用,然后第二个可调用对象紧接着会被调用或阻塞。
这种书写风格在 v3.0 中已经被淘汰了,使用两个可调用对象的布局方案被认为是没必要的。现在它们已经成为旧时代的遗物被用来支持一些仍旧以这种风格编写的程序。
asgiref.compatibility 模块中有一个兼容性套件,你可以用它来检测旧版风格的应用程序,并将其无缝切换为新版但可调用对象的风格。虽然现在这种遗留的风格仍被支持,但它终究会随着时间的推移而被放弃,所以请尽可能使用新版的风格进行代码开发。
具体的协议规范描述了在指定协议下 Scope 和 Event 消息格式的标准化规范。
所有协议中的 Scope 和 Event 消息中都存在的一个键就是 type,它的值代表了 Scope 或 Event 消息的类型(协议类型)。
在 Scope 中,type 的值必须是一个 Unicode 字符串,例如 "http"
或 "webscoket
,具体参考相关协议子规范的规定。
在消息中,type 应该被命名为 protocol.message_type
其中 protocol 与 Scope 中的 type 相匹配,message_type 由协议子规范定义。消息类型值的示例包括:http.request
,websocket.send
。
注意:Application 应该主动拒绝任何未被定义(无法理解)的协议,并给出一个任意类型的异常。如果不这么做,可能会导致服务器认为你支持一个你并不支持的协议,这在于 Lifespan 协议一起使用时可能会产生混淆,因为 Server 会等你主动启动它。
当前支持的协议子规范有:
ASGI 同样拥有中间件这个概念,中间件同时拥有 Server 和 Application 的功能,能够接收一个 Scope,也能发送或接收异步可调用对象,可以对其内部进行修改并执行内部的 Application。
当中间件修改 Scope 的时候,它应该在改变 Scope 并将其传递给 Application 之前制作一个备份,以防止 Scope 的改变向上层泄漏。我们在构建中间件时不能想当然的认为中间件发送的 Scope 就是最终版本,因为这中间可能有其他的中间件阻挡。正因如此,不要在中间件中保持对 Scope(包括副本)的引用,更不要尝试在 ASGI app 的外围去改变它。修改 Scope 最好的时机就是将控制权交给子 Application(中间件中的 Application) 之前。
如果 Server 接收到一个错误的 Event 字典,例如:包含一个未知类型的、缺少 Event type 必要键的或者对象有错误 Python 类型(例如 HTTP 头信息的 Unicode 字符串),这种情况下应该引发一个异常,从异步可调用对象到 Application。
如果一个 Application 从 receive 接收到一个无效的事件字典,也应该有引发一个异常。
但如果是字典中存在额外的键,则不应该引发异常。这就允许在后续过程中对协议规范进行非破坏性的升级或定制。
Server 可以自由地处理运行在其中的 Application 抛出的异常——记录到控制台,发送到 syslog 或其他自定义操作——但一旦发生异常 Server 就必须终止 Application 实例和其中的相关 Connection。
注意在 Connection 关闭后 Server 接收到的 message 不被视为错误,在这种情况下 send 异步可调用对象应该充当一个 no-op。
Frameworks 或 Applications 可能会希望在为每个 Application 启动协同程序外运行额外的协同程序。但由于在 Python 3.7 中无法将额外的程序设置为实例的协同程序的父级,Application 应该确保所有在 Application 运行时启动的协程与 Application 对应的协程同时关闭或在此之前关闭。
任何在 Application 对应协程关闭后仍在运行的协程都不能保证它能够被顺利执行完毕,因为它可能在任意时间被强制退出。
在有些情况下,我们可能想在核心 ASGI 规范之外提供特定的拓展,或在某个规范推出之前对其进行测试。
为了应对这些场景,ASGI 定义了一种常见的拓展模式——可以对协议规范进行选择性补充,Server 可以利用这个机制使 Application 获得更多的功能。
这一切都是通过 scope 词典的 extensions 条目实现的,它本身对应的也是一个字典。Extensions 字典中的 Unicode 字符串名称是由 Server 和 Application 共同约定而成的。
如果 Server 想要支持一个拓展,需要在 extensions 字典中增加一个条目,并且增加的条目的值也应该是一个字典。Server 可以用这个字典中提供任对 scope 的拓展信息,如果 extension 仅用来表明 Server 的 send 可调用对象允许额外 events,只需要给相关 extension 条目附上一个空字典的值就可以了。
假设一个提供共 HTTP 服务的 Server 希望提供一个允许某个新 event 被返回的拓展,这个事件会导致刷新操作系统级别的网络发送缓冲区,那就可以通过一下格式进行拓展:
1 | scope = { |
当某个 Application 接收到包含此 scope 的请求后,它就能在调用指定可调用对象时触发相关自定义事件(本例中就是 http.fullflush)。
在本文档以及所有相关子规范中:
byte string:Python3 中的 bytes 类型;
Unicode string:Python3 中的 str 类型。
本文档永远不会使用 string 来模糊两者之间的表达,在涉及这两个概念的地方都使用了 str 和 bytes 作为区分。
本文档中所有字典的键(包括 scopes 和 events 中的字典)都是 Unicode string。
HTTP 格式涵盖了 HTTP/1.0 HTTP/1.1 HTTP/2,HTTP/2 的变动主要集中在传输层。支持 HTTP/2 的 ASGI Server 应该为同一个 HTTP/2 上的不同请求生成不同的 scopes,并且能够将来自同一个流的响应正确地复用。HTTP 版本会在 scope 中以字符串的形式存在。
处理HTTP 协议中具有相同名字的 header 字段是很复杂的。RFC 7230 规定:在处理任何可重复出现的 header 字段时,都视作只发送一次该 header 字段并将所有的值使用逗号连接。
但同时 RFC 7239 和 RFC 6265 也明确指出,这一规则不适用于 HTTP Cookie 相关的 header 字段(Cookie 和 Set-Cookie)。Cookie header 字段只能由 user-agent 发送一次,但是 Set-Cookie header 字段可以重复出现并且不能使用逗号连接。
ASGI 协议的抉择是将请求和响应头分为两组 [name, value]
列表并且不对传入的值做任何其他处理。
ASGI Server 需要在向 ASGI Application 发出请求时通过一个值为 http 的 tpye 字段表明使用的协议。
HTTP Connection 拥有一个唯一的 Request Connection Scope,也就是说,ASGI Application 会在请求开始时被调用,并持续到特定的请求结束后,即使底层的 Socket 仍然处于开启状态并且持续有请求进入。
Connection Scope Message 包括:
名称 | 类型 | 描述 |
---|---|---|
type | Unicode string | “http” |
asgi[“version”] | Unicode string | 使用的 ASGI 规范版本 |
asgi[“spec_version”] | Unicode string | Server 支持的 ASGI HTTP 规范版本,可选值:”2.0”,”2.1”,”2.2” 或 “2.3”,默认”2.0” |
http_version | Unicode string | 可选值:”1.0”,”1.1” 或 “2” |
method | Unicode string | HTTP 方法名称,大写 |
scheme | Unicode string | URL 协议,可选值:”http” 或 “https”,默认为 “http” |
path | Unicode string | HTTP 请求目标,不包括任何查询字符串,由百分号编码和 UTF-8 字节序解码成字符 |
raw_path | byte string | 原始的未被修改的 HTTP path,来自 Web Server,可能缺省,默认为 None |
query_string | byte string | url 中 ? 后的部分,百分号编码 |
root_path | Unicode string | Application 被绑定到的根路径,和 WSGI 中的 SCRIIPT_PATH 一致,默认为 “” |
headers | Iterable[[byte string, byte string]] | 一个由 [name, value] 两个子项组成的可迭代对象,name 应尽可能是小写 |
client | Iterable[Unicode string, int] | 一个 [host, port] 可迭代对象,默认为 None |
server | Iterable[Unicode string, Optional[int]] | 可以是 [host, port] 可迭代对象,也可以是 [path, None],其中 path 是 unix 套接字路径,缺省为 None |
ASGI Server 应该负责处理所有入站和出站的分块传输编码。当一个带有 chunked encoded body 的请求通过 ASGI Server 时,它应该自动去掉请求的分块以 plain body bytes 的形式提供给 ASGI Application。当一个没有 Content-Length 的响应被提供给 ASGI Server 时,它可以按照合适的方式进行 chunked。
由 ASGI Server 发送给 ASGI Application 以标识一个入站请求。关于这个请求的大部分信息都在对应的 Connection Scope 内。
Receive 中的 body message 是一种传输大量大的入站 HTTP body 块的方式,并且是判断何时执行「实际处理请求的代码」的触发器(因此不应该在仅有一个 Connection Scope 打开时就触发「实际处理请求的代码」)。
注意:如果请求发送时附带了 Transfer-Encoding: chunked
头,ASGI Server 需要负责处理这种编码。http.request message 应该只包含每个 chunk 的解码信息。
Request Receive Event Message 包含:
名称 | 类型 | 描述 |
---|---|---|
type | Unicode String | “http.request” |
body | Byte String | 请求主体,默认为 b””,如果设置了 more_body = True,则将其视为 body chunk 链的一部分,并与后续的 chunks 进行关联 |
more_body | Bool | 标志着是否还有额外的 body 内容,如果是 True 表示还有 body 内容,ASGI Application 需要等待,知道有一个为 False 的 chunk 到达 |
由 ASGI Application 发送给 ASGI Server,用于标识开始向 Web Client 发送响应。在此之后需要紧跟至少一个 response content message。ASGI Server 在接收到至少一个 Response Body 之前不得向 Web Client 发送响应。
ASGI Application 可能会在消息中发送一个 Transfer-Encoding header,但是 ASGI Server 必须忽略它。ASGI Server 需要自己处理 Transfer-Encoding,如果应用程序呈现的响应没有设置 Content-Length,可以选择使用 Transfer-Encoding: chunked
。
Response Start Send Event Message 包含:
名称 | 类型 | 描述 |
---|---|---|
type | Unicode String | “http.response.start” |
status | int | HTTP 状态码 |
headers | Iterable[[byte string, byte string]] | 一个 [name, vlaue] 迭代器,必须与 HTTP Response 中的顺序一致且 Header names 都必须是小写的。默认是 []。不能存在 Pseudo headers(HTTP/2 和 HTTP/3 中的) |
由 ASGI Application 发送给 ASGI Server,用于继续向 Web Client 发送响应。ASGI Server 必须在 send 返回前将传递给它的全部数据传输到发送缓冲区。如果 more_body 被设置为 False,这个 Connection 将被关闭。
Response Body Send Event Message 包含:
名称 | 类型 | 描述 |
---|---|---|
type | Unicode String | “http.response.body” |
body | byte string | |
more_body | bool |
由 ASGI Server 发送给 ASIG Application,在 HTTP Connection 关闭或在响应被发送后调用。主要适用于长轮询(long-polling),如果连接被提前关闭,希望触发某些清理代码时。
Disconnect Receive Event Message 包含:
名称 | 类型 | 描述 |
---|---|---|
type | Unicode String | “http.disconnect” |
WebSockets 与 HTTP 存在某些一致的细节:它们都有 path 和 headers,但是也有一些独特的状态。同样,大部分状态都在 Scope 中,只要 socket 存在它们就会一直存在。
WebSocket 协议服务器(后简称:ASGI Server)应该自行处理 PING/PONG 消息,并在必要时发送 PING 消息以确保 Connection 是有活性的。
ASGI Server 应该自行处理 message fragmentation,并且将完整的消息传递给 ASGI Application。
ASGI Server 需要在向 ASGI Application 发出请求时通过一个值为 websocket 的 tpye 字段表明使用的协议。
WebSocket Connection Scope 应该与 socket 共存,如果连接中断,socket 应该被同时关闭,反之亦然。
WebSocket Connection Scope Message 包含:
名称 | 类型 | 描述 |
---|---|---|
type | Unicode String | “websocket” |
asgi[“version”] | Unicode String | 使用的 ASGI 规范版本 |
asgi[“spec_version”] | Unicode String | Server 支持的 ASGI HTTP 规范版本,可选值:”2.0”,”2.1”,”2.2” 或 “2.3”,默认”2.0” |
http_version | Unicode String | “1.1” 或 “2” 默认是 “1.1” |
scheme | Unicode String | URL 协议,可选值:”ws” 或 “wss”,默认为 “ws” |
path | Unicode string | HTTP 请求目标,不包括任何查询字符串,由百分号编码和 UTF-8 字节序解码成字符 |
raw_path | byte string | 原始的未被修改的 HTTP path,来自 Web Server,可能缺省,默认为 None |
query_string | byte string | url 中 ? 后的部分,百分号编码 |
root_path | Unicode string | Application 被绑定到的根路径,和 WSGI 中的 SCRIIPT_PATH 一致,默认为 “” |
headers | Iterable[[byte string, byte string]] | 一个由 [name, value] 两个子项组成的可迭代对象,name 应尽可能是小写 |
client | Iterable[Unicode string, int] | 一个 [host, port] 可迭代对象,默认为 None |
server | Iterable[Unicode string, Optional[int]] | 可以是 [host, port] 可迭代对象,也可以是 [path, None],其中 path 是 unix 套接字路径,缺省为 None |
subprotocols | Iterable[Unicode string] | 客户端公布的子协议,默认是 [] |
此事件是在 Web Client 打开一个 connection 并即将完成 WebSocket 握手的时候,由 ASGI Server 发送给 ASGI Application 的。
本事件的消息体必须被一个 Accept 事件消息或一个 Close 事件消息响应,在对应 socket 将要传递 websocket.receive 事件消息之前。ASGI Server 必须在 WebSocket 握手阶段发送本事件消息,并且在得到回复之前不能完成握手,如果 connection 被拒绝,则返回 HTTP 403。
Connect Receive Event Message 包括:
名称 | 类型 | 描述 |
---|---|---|
type | Unicode String | “websocket.connect” |
此事件是在 ASGI Application 期望接受一个 incoming connection 时由 ASGI Application 向 ASGI Server 发送的。
Accept Send Event Message 包括:
名称 | 类型 | 描述 |
---|---|---|
type | Unicode String | “websocket.accept” |
subprotocol | Unicode String | ASGI Server 期望接受的子协议,可选值,默认为 None |
headers | Iterable[[byte string, byte string]] | 一个 [name, value] 的可迭代对象。更多描述请看原文。 |
此事件是在收到来自 Web Client 的数据消息时由 ASGI Server 发送给 ASGI Application 的。
Receive Event Message 包括:
名称 | 类型 | 描述 |
---|---|---|
type | Unicode String | “websocket.receive” |
bytes | Byte String | message content,binary 模式,可选项,默认为 None |
text | Unicode String | message content, text 模式,可选项,默认为 None |
bytes 或 text 至少要存在一个,也可以两个都存在。
由 ASGI Application 发送给 ASGI Server,为 Web Client 发送一条数据信息。
Send Event Message 包括:
名称 | 类型 | 描述 |
---|---|---|
type | Unicode String | “websocket.send” |
bytes | Byte String | 同上 |
text | Unicode String | 同上 |
bytes 或 text 至少要存在一个,也可以两个都存在。
此事件是在任何一个与 Web Client 的链接断开时(包括 Web Client 关闭连接、ASGI Server 关闭连接或 socket 丢失)由 ASGI Server 发送给 ASGI Application 的。
Disconnect Reveive Event Message 包括:
名称 | 类型 | 描述 |
---|---|---|
type | Unicode String | “websocket.disconnect” |
code | int | websocket close code |
由 ASGI Application 发送给 ASGI Server 告知 connection 关闭。
如果在 socket 被接受之前发送,ASGI Server 必须以 HTTP 403 错误代码关闭 connection,并且不完成 WebSocket 握手,这在某些浏览器上可能表现为不同的 WebSocket 错误代码(例如 1006,异常关闭)。
如果在 socket 被接受后发送,ASGI Server 必须通过传递 message 关闭 socket(默认 code 是 1000)。
Close Send Event Message 包括:
名称 | 类型 | 描述 |
---|---|---|
type | Unicode String | “websocket.close” |
code | int | websocket close code,可选项,默认为 1000 |
reason | Unicode String | close 原因,可选项,默认为空字符串 |
HTTP 子协议的设计有一部分是为了确保它能够与 WSGI 规范保持一致,以降低兼容两种规范的难度,并且使得 ASGI Server 搭配 WSGI Application 成为可能。
WSGI Application 是同步的,必须运行在线程池中才能被驱动,否则它的 runtime 就会映射到 HTTP Connection Scope 的 lifetime 上。
WSGI 的 environ 变量中的各种特殊 key 几乎都可以直接映射到 HTTP Connection Scope 上:
WSGI environ | ASGI HTTP Scope |
---|---|
REQUEST_METHOD | method |
SCRIPT_NAME | root_path |
PATH_INFO | 从 root 中剥离 root_path 获得 |
QUERY_STRING | query_string |
CONTENT_TYPE | 从 headers 中剥离 |
CONTENT_LENGTH | 从 headers 中剥离 |
SERVER_NAME and SERVER_PORT | server |
REMOTE_HOST/REMOTE_ADDR and REMOTE_PORT | client |
SERVER_PROTOCOL | http_version |
wsgi.url_scheme | scheme |
wsgi.input | 一个基于 http.request 的 StringIO |
wsgi.errors | directed by the wrapper as needed |
WSGI 中的 start_response 可调用对象与 http.response.start 类似:
从 WSGI Application 中产生的内容映射到了 http.response.body 的 message 中。
WSGI 规范(如 PEP 3333 所定义)规定:所有发送或来自 WSGI Server 的 strings 必须是 str 类型,但值包括 ISO-8859-1(“lantin-1”) 范围内的 codepoints。这是因为它最迟是为 Python2 以及不同的 set of string types 设计的。
ASGI 的 HTTP 和 WebSocket 子规范将 Scope 字典的每个条目指定为 byte string 或 Unicode String 的其中一种。HTTP 作为一个早期协议,在编码制定上存在一些不完善的地方,所以在何处使用 Unicode 何处使用 byte 并没有明确的说明。
Lifesapn ASGI 子规范概括了如何在 ASGI Application 中传递像 startup 或 shutdown 之类的 lifespan events。
Lifespan message 允许 ASGI Application 在一个运行中的事件循环的上下文中初始化或停止。这方面的一个例子是创建一个连接池,随后关闭连接池释放连接。
Lifespan 应该在处理请求的每个事件循环中执行一次。在多进程环境中,每个进程都会有 Lifespan event。重要的是,lifespan 和 request 是在同一个事件循环中运行的,以确保像数据库连接池这样的对象不会在循环中被移除或共享。
一个 lifespan 实现举例:
1 | async def app(scope, receive, send): |
Lifespan Scope 会持续存在直到事件循环结束。
Lifespan Scope Message 包含:
名称 | 类型 | 描述 |
---|---|---|
type | Unicode String | “lifespan” |
asgi[“version”] | Unicode String | ASGI 协议版本 |
asgi[“spec_version”] | Unicode String | 子协议版本,默认 “1.0” |
如果在调用带有 lifespan.startup 消息的 Application 或处理 type 是 lifespan 的 Scope 时抛出了异常,ASGI Server 需要继续执行但不 send any lifespan events。
这允许兼容不支持 lifespan 的 ASGI Application。如果需要记录在 lifespan 启动过程中发生的错误并阻止 ASGI Server 的启动过程,可以通过发送 lifespan.startup.filed 来实现。
此事件是在 ASGI Server 准备好处理 startup 和 receive connection 但还没开始处理的时候,由 ASGI Server 发送给 ASGI Application 的。
Startup Receive Event Message 包括:
名称 | 类型 | 描述 |
---|---|---|
type | Unicode String | “lifespan.startup” |
此事件是在 ASGI Application 处理完 startup 后,由 ASGI Application 发送给 ASGI Server 的。ASGI Server 在开始处理 connection 之前必须等待这个事件。
Startup Complete Send Event Message 包括:
名称 | 类型 | 描述 |
---|---|---|
type | Unicode String | “lifespan.startup.complete” |
此事件是在 ASGI Application 未能完成 startup 时,由 ASGI Application 发送给 ASGI Server 的。ASGI Server 应该在接收到事件消息后记录或打印消息所提供的内容,然后退出。
Startup Failed Send Event Message 包括:
名称 | 类型 | 描述 |
---|---|---|
type | Unicode String | “lifespan.startup.failed” |
message | Unicode String | 默认为 “” |
此事件是在 ASGI Server 停止接受 connection 并关闭所有正在处理的 connection 后,由 ASGI Server 发送给 ASGI Application 的。
Shutdown Reveive Event 包括:
名称 | 类型 | 描述 |
---|---|---|
type | Unicode String | “lifespan.shutdown” |
此事件是在 ASGI Application 完成 cleanup 之后,由 ASGI Application 发送给 ASGI Server 的。ASGI Server 在终止前必须等待此事件消息。
名称 | 类型 | 描述 |
---|---|---|
type | Unicode String | “lifespan.shutdown.complete” |
此事件是在 ASGI Application 未能正常处理 cleanup 之后,由 ASGI Application 发送给 ASGI Server 的。ASGI Server 应该在接收到事件消息后记录或打印消息所提供的内容,然后退出。
名称 | 类型 | 描述 |
---|---|---|
type | Unicode String | “lifespan.shutdown.failed” |
message | Unicode String | 默认 “” |
本规范概述了如何在 ASGI connection scope 对象中传递 TLS(或 SSL)connection 信息。
TSL 是无法单独使用的,它总是包裹着另一个协议。因此,此规范并非用来规定如何单独使用 TSL 的,它必须作为一个其他 ASGI 子协议的拓展来使用。与 TSL 搭配的其他 ASGI 子协议被称为基础协议。
对于 HTTP-over-TLS(HTTPS) 来说,需要联合使用 TLS 子规范和 ASGI HTTP 子规范,其中基础协议是 ASGI HTTP 子规范。
对于 WebSockets-over-TLS(wss:// protocol) 来说,需要联合使用 TLS 子规范和 ASGI WebSocket 子规范,其中基础协议是 ASGI WebSocket 子规范。
在搭配基础协议使用此拓展协议时需要注意:基础协议中必须定义 Connection Scope 以确保它最多包含一个 TLS 连接,否则,就不能使用此拓展协议。
此拓展仅限于在 TLS Connection 中使用。
对于非 TLS Connection,ASGI Server 禁止提供此拓展。
ASGI Application 可以在 Connection Scope 的 extensions 字典中检查是否存在 tls 拓展。如果存在,说明 ASGI Server 支持此拓展,并且 Connection 是建立在 TLS 上的。如果不存在,说明 ASGI Server 不支持这个拓展或 Connection 不是建立在 TLS 上的。
基础协议的 Connection Scope 中包含一个 extensions 键值对,它的值是一个字典。在该字典中的 tls 键值即是 TLS Connection Scope:
名称 | 类型 | 描述 |
---|---|---|
server_cert | Unicode String or None | ASGI Server 在建立 TLS 连接时发送的 x509 证书的 PEM 编码版本。一些 ASGI Server 的实现可能无法提供这一点(例如,如果 TLS 是由一个单独的代理或负载平衡服务器终止的),在这种情况下应该是 None。必须存在的。 |
client_cert_chain | Iterable[Unicode string] | 每个字符串都是一个 PEM 编码的 x509 证书。第一个证书是客户端证书。任何后续证书都是客户端发送的证书链的一部分,每个证书都会签署前面的证书。如果客户端没有提供证书,那么它就是一个空的可迭代对象。可选的,如果缺失默认为空的可迭代对象。 |
client_cert_name | Unicode String or None | 客户端证书主题的 x509 标识名(DN),按照 RFC 4514 中定义进行编码的单一字符串。如果客户端没有提供证书,则为 None。如果 client_cert_chain 被提供且不是空可迭代对象,那么这个字段必须要被提供,并且必须包含与 client_cert_chain[0] 一致的信息。可选的,如缺失默认为 None。 |
client_cert_error | Unicode String or None | 如果提供了客户端证书并验证成功,或没有提供客户端证书都为 None。如果提供了客户端证书但验证失败,则是一个非空字符串,包含一个错误信息或错误代码,表明验证失败的原因。大多数 Web Server 会在客户端证书验证失败后直接拒绝而非设置这个值。可选的,默认为 None。 |
tls_version | Integer or None | 正在使用的 TLS 版本。这是在 TLS 规范中定义的版本号之一,是一个无符号整数。常见值包括:0x0303 for TLS 1.2 或 0x0304 for TLS 1.3,如果 TLS 没有被使用则设置为 None。必须存在。 |
cipher_suite | Integer or None | 正在使用的 TLS cipher suite。一个 16 位无符号证书,按照网络字节序对相关 RFC 中规定的一对 8 位证书进行编码。一些 Web Server 无法提供这个功能,这种情况下,设置为 None。必须存在。 |
所有的 Event 都是基于基础协议的。
略
Stable / http://github.com/django/daphne
当前 ASGI 的参考 Server 实现(亲儿子),使用 Twisted 编写,作为 Django Channel 项目组的一部分。支持 HTTP/1, HTTP/2 和 WebSockets。
Stable / https://www.uvicorn.org/
一个基于 uvloop 和 httptools 的 ASGI Server。支持 HTTP/1 和 WebSockets。
Beta / https://pgjones.gitlab.io/hypercorn/index.html
一个基于 sans-io hyper, h11, h2 和 wsproto 库的 ASGI Server。支持 HTTP/1, HTTP/2 和 WebSockets。
Stable / http://channels.readthedocs.io
Channels 是 Django 项目的一部分,旨在为 Django 提供异步支持能力,是 ASGI 项目的发起者。为 Django 整合了处理 HTTP,WebSocket 以及任何满足 ASGI-native 代码实现协议的能力。
Beta / https://github.com/tiangolo/fastapi
FastPI 是一个在 Starlette 框架基础上进一步封装的 ASGI Web 框架,它整合了标准 Python 类型注释、OpenAPI、JSON Schema、OAuth 等特性。支持 HTTP 和 WebSocket 协议。
Beta / https://github.com/pgjones/quart
Quart 是一个 Python ASGI 微框架。它专注于使用最简单的 asyncio 特性为 Web 应用提供异步能力,常被用在 Flask apps 中。支持 HTTP 协议。
Beta / https://sanicframework.org
Sanic 是一个灵活的框架,它既能够作为 ASGI Server 使用也能够作为 ASGI Application 使用。支持 HTTP 和 WebSockets 协议。
Beta / https://github.com/encode/starlette
Starlette 是一个提供了编写基础但强大请求或响应对象的极简的 ASGI 库。支持 HTTP 和 WebSockets 协议。
Beta / https://github.com/abersheeran/rpc.py
一个易于使用的强大的 RPC 框架。RPC Server 基于 WSGI 和 ASGI,Client 基于 httpx。支持同步、异步、同步生成器和异步生成器,并提供可选的类型注释和 OpenAPI 文档生成特性。
Stable / https://github.com/abersheeran/a2wsgi
将一个 WSGI Application 转换为一个 ASGI Application 的工具。纯 Python 实现,仅依赖于原生库。
我在整理 已有实现章节 的时候发现,encode 这个 Github Group 有点强,Django-REST-Framework, Starlette, Uvicorn, httpx 都是出自这一个组织。
在学习 Python 的过程中阅读 PEP 是绕不过去的一件事情,所有新鲜的 Python 特性都是经过 PEP 讨论和公示后才加入到新版本中的。因此无论是想要了解某个现有 Python 特性的详细说明,还是想要了解某个 Python 新特性的形成过程,阅读 PEP 都是一个不二的选择。
WeeklyPEP 系列文章的目的是:
每月至少围绕一个 PEP 进行更新,并将至少一个 PEP 的核心内容理解和整理成文。
常规:WeeklyPEP-[number]-[PEP number]-[PEP name]
对于 Python 重要特性的 PEP 可能会拆分成多篇文章进行更新:
WeeklyPEP-[number]-[PEP number]-[PEP name]-overview
WeeklyPEP-[number]-[PEP number]-[PEP name]-feature
Secure Shell(安全外壳协议,简称 SSH)是一种加密的网络传输协议,可以再不安全的网络中为网络服务提供安全的传输环境。
——维基百科
通过维基百科的说明可以看出 SSH 实际上指的是一种加密的网络传输协议,而我们经常用来登录远程主机的 ssh 命令实际上是某个软件对 SSH 这种协议的包装实现,其中最常见的开源实现方案是 OpenSSH(OpenBSD Secure Shell)。
SSH 目前还是比较可靠的,利用 SSH 协议可以有效防止远程管理过程中的信息泄露问题。通过 SSH 可以对所有传输的数据进行加密,也能够防止 DNS 欺骗和 IP 欺骗。SSH 的另一项优点是其传输的数据可以是经过压缩的,所以可以加快传输速度。
SSH 当下有两个版本,分别是 SSHv1 和 SSHv2,v2 是主流版本,v1 版本存在中间人攻击的安全风险。
SSH 协议规定的通讯流程可以分解成几个主要阶段:
DH 算法可以在一个不安全的信道上建立安全连接,从而解决不安全信道上信息安全交互的问题。
假设 A 与 B 要在不安全信道上使用 DH 算法安全地交换信息,大致流程如下:
注 1:流程中的数理知识这里不做普及,因为博主本身也不是很懂;
注 2:这里给出的仅仅是一个粗略的原理解释,并非最佳实现过程;
算法成立的原因:再此方法中公开数据有 $p,g,Y_{A},Y_{B}$,若想要通过公开数据计算 $K$,则需要求取 $Y_{A} = g^{a} mod p \mid Y_{B} = g^{b} mod p$ 中的 a 或 b,求解此类问题一般使用穷举法,时间复杂度为 $O(p)$,只要 p 足够大就能够保证此方法目前可以达到计算机安全的要求。
博主技术有限,没筛选出这一流程的 TCP 包,因此参考 第三篇参考文章 给出 diffie-hellman-group-exchange-sha256 的大致实现流程:
H 的计算方法:$H = hash(VC \parallel VS \parallel IC \parallel IS \parallel KS \parallel YC \parallel YS \parallel K)$
类型 | 名称 | 意义 |
---|---|---|
string | VC | 客户端的初始报文 |
string | VS | 服务端的初始报文 |
string | IC | 客户端 SSH_MSG_KEX_INIY 的有效载荷 |
string | IS | 服务端 SSH_MSG_KEX_INIT 的有效载荷 |
string | KS | 服务端主机密钥(host key 一般是 RSA 公钥) |
string | YC | Y-客户端 |
string | YS | Y-服务端 |
string | K | 通过 DH 产生的共享密钥 |
以上内容按顺序进行拼接,不夹杂或尾随多余字符,拼接后的字符串进行 sha256 计算出结果就是 H 即 session_id。只有会话第一次密钥交换生成的 H 是 session_id,后面再进行密钥交换时,session_id 不会改变。
后续通信一般是采用 AES 算法进行加密,密钥计算方法:$hash(K \parallel H \parallel W \parallel session-id)$,其中 W 代指单个大写的 ASCII 字母,不同的加密秘钥使用不同的字符来计算。
字母 | 类型 |
---|---|
A | 客户端到服务端的初始 IV |
B | 服务端到客户端的初始 IV |
C | 客户端到服务端的加密秘钥 |
D | 服务端到客户端的加密秘钥 |
E | 客户端到服务端的完整性秘钥 |
F | 服务端到客户端的完整性秘钥 |
经过计算得到字符串 RE,如果我们想要的秘钥长度比 RE 长,则在 RE 后面继续加上一个值:$hash(K \parallel H \parallel RE)$ 成为一个加长的 RE。如果还不够,则继续使用同样方法进行累加。
注 1:关于秘钥计算公式中 H 和 session-id 同时出现博主表示存疑,但是没找到更多资料所以暂时先这么写了,如果具体实现和给出内容有出入的话希望您能不吝赐教,第一时间联系博主进行修改。
我对 SSH 协商过程的理解:
- A 利用 RSA 算法生成公钥 A 和私钥 A,B 同上生成公钥 B 和私钥 B;
- A 把公钥 A 发给 B;
- B 把公钥 B 发给 A;
- 后续通讯过程 A 用公钥 B 加密内容发给 B;B 用公钥 A 加密内容发给 A。
我的疑惑是:
看很多资料在解释Linux下两台主机ssh通信协商时会提到DH(diffie-hellman),我知道DH是密钥交换算法,可以使通信双方安全地产生一个公共密钥(对称密钥)。但是通过上述> 上述协商过程,A 和 B 不是已经可以利用 RSA 算法产生的公钥和私钥进行加密通信了吗,那为什么还需要 DH 算法呢?
难道上述过程之后还要用 DH 算法再生成一个公共密钥?
首先要指出的是问题提出者所理解的 SSH 协商过程是错误的。
至于为什么不直接用 RSA 算法进行加密通信其实和 HTTS 差不多一个道理:RSA 算法是非对称加密算法,消耗资源多,运行效率低,不适合用于长连接下的数据交换加密。
如果想看此问题下的更多展开内容推荐 点击此链接查看第六篇参考文章 给出的回答。
常见的客户端认证方式有两种
~/.ssh/authorized_keys
中,注意需要 SSH 服务端拥有此文件的访问权限。OpenSSH 是在 1999年 10月第一次在 OpenBSD 2.6 里出现,当初的项目是取代由 SSH Communications Security 所提供的 SSH 软件。
——维基百科
程序主要包括了几个部分:
常用选项:
选项 | 含义 | 作用 |
---|---|---|
-t | type | 指定要生成的密钥类型 |
-C | comment | 提供一个注释 |
-b | bits | 指定要生成的密钥长度(单位:bit) |
-f | filename | 指定生成的密钥文件名 |
ssh-agent 是 OpenSSH 开发的用户提供 ssh 代理的工具,它可以为其他需要使用 ssh key 的程序提供代理。
ssh-add 是用来配合 ssh-agent 的,使用此工具可以向 ssh-agent 中添加私钥。
可以用过 ssh-add -l
查看已经添加的私钥列表。
注:本文中给出的所有案例结果都经过实际代码验证可放心食用。
方法解析顺序(Method Resolution Order MRO),指的是在多继承编程语言中查找类的某个方法来自哪个基类的搜索顺序。
周期 | 类存在形式和对应算法 |
---|---|
Python 2.1 | 经典类 -> DFS |
Python 2.2 | 经典类 -> DFS | 新式类 -> BFS |
Python 2.3-2.7 | 经典类 -> DFS | 新式类 -> C3 |
Python 3 | 新式类 -> C3 |
在 Python 2.1 及以前,定义一个类的形式如下:
1 | class A: |
在 Python 2.2 中引入了一种新的类的定义方式:
1 | class A(object): |
为了保持向上兼容,Python 2.2 及以后的版本同时保留这两种定义方式而两种方式产生的类及其实例具有不同的特性,为了区分两种定义方式将 Python 2.1 及以前版本的书写方式产生的类称为经典类(Old-style Class)将 Python 2.2 及以后版本的书写方式产生的类称为新式类(New-style Class)。
伴随着 Python3 的推出经典类已经被完全废弃,因为在 Python3 中无论以何种方式定义产生的都是新式类。
参考 [DFS 搜索流程](#DFS 搜索流程),搜索顺序为:A -> B -> D -> H -> E -> C -> F -> G
由于未能在本地下载 Python 2.2 因此无法验证复杂的案例,故这里给出网上反复论证过的案例。
参考 [BFS 搜索流程](#BFS 广度优先搜索),搜索顺序为:A -> B -> C -> D
从 [Python MRO 历史](#Python MRO 历史) 可以看出无论是 DFS 还是 BFS 最终都被 C3 算法代替了,原因是 DFS 和 BFS 在处理复杂继承关系时会出现无法满足局部优先或单调性的问题。
查找过程应该按照子类声明时给定的父类顺序进行查找(从左向右)。
在多继承关系中,假设类 C 的 MRO 结果给出类 A 排在类 B 前面,则在类 C 的所有子类中也需要满足同样的先后顺序;
1 | import inspect |
从结果来看,在类 C 的 MRO 中类 D 在类 C 之后,而在类 A 的 MRO 中类 D 在类 C 之前。
由于未能在本地部署 Python 2.2 环境,本案例取自 参考文章-1。
1 | class A(object): pass |
从结果来看,类 C 的 MRO 类 B 在类 A 之前,而在类 C 的声明中类 B 在类 A 之后。
在了解了单调性和局部优先原则以及两个算法的反例之后我又产生了一个疑惑,为什么不满足单调性和局部优先的原则就无法使用,不满足这两个原则所带来的弊端有哪些?
经过简单的思考和搜索我并没有得出答案,以后如果有机会能够实际解除此类问题我会再回来补充。
假设有类 C 继承自类 B1 到 类 BN,则记类 C 的 MRO 为 L[C](L 代表 linearization,线性化),假设 L[C] 最终的结果是一个特殊的 Python list;
对于 L[C] = [B1, B2, B3, … BN],设 [B1] 为 L[C] 的头部,[B2, B3, …, BN] 为 L[C] 的尾部;
所有类都会继承 object,且 L[object] = object, 以下 object 简写为 o;
定义 L[C(B1, B2, …, BN)] = [C] + merge(L[B1], L[B2], …, L[BN], L[o], [B1, B2, …, BN, o])
通过定义可知,整个搜索流程即是 merge 方法运算的流程(假设被搜索的被是类 C):
注意几个点:
先来看一下前面给出的两个失败案例在 C3 算法下的输出结果:
1 | class D: |
C3 执行结果:
A: A -> B -> C -> D -> o
B: B -> D -> o
C: C -> D -> o
DFS 执行结果:
A: A -> B -> D -> C
B: B -> D
C: C -> D
从结果对比来看 C3 算法输出的结果解决了 DFS 算法在此案例中无法满足单调性原则的问题。
下面我们来看一下 C3 算法是如何输出这样的结果的,重点看类 A 的 MRO 生成过程:
注:在展示 merge 方法执行流程时使用加粗的 [] 代表当前列表,使用被 代码块
包裹的类代表待检测类
B
, D, o], [C, D, o], [o], [B, C, o])D
, o], [C, D, o], [o], [C, o])C
, D, o], [o], [C, o])D
, o], [o], [o])o
], [o], [o])1 | class A: pass |
从结果来看,C3 算法最终会通过引起异常来维护局部优先原则。
下面我们来看一下 C3 算法是如何输出这样的结果的,重点看类 C 的 MRO 生成过程:
注:在展示 merge 方法执行流程时使用加粗的 [] 代表当前列表,使用被 代码块
包裹的类代表待检测类
A
, o], [B, A, o], [o], [A, B, o])B
, A, o], [o], [A, B, o])o
], [A, B, o])A
, B, o])直至 merge 检查完所有参数 list,仍然存在非空参数 list,因此 merge 抛出异常。
这里我们通过 维基百科 给出的复杂案例来进行展示:
注:由于案例过于复杂,这里就不展示源码了,只展示案例依赖图、最终结果和执行过程;
K1
, C, A, B, o], [K3, A, D, o], [K2, B, D, E, o], [o], [K1, K3, K2, o])C
, A, B, o], [K3, A, D, o], [K2, B, D, E, o], [o], [K3, K2, o])A
, B, o], [K3, A, D, o], [K2, B, D, E, o], [o], [K3, K2, o])K3
, A, D, o], [K2, B, D, E, o], [o], [K3, K2, o])A
, D, o], [K2, B, D, E, o], [o], [K2, o])D
, o], [K2, B, D, E, o], [o], [K2, o])K2
, B, D, E, o], [o], [K2, o])B
, D, E, o], [o], [o])D
, E, o], [o], [o])E
, o], [o], [o])o
], [o], [o])最终得到结果为:Z -> K1 -> C -> K3 -> A -> K2 -> B -> D -> E -> o
本文只是对 shell 脚本语言中一些常用的基础语法进行汇总整理,如果你真的想系统的学习 shell 脚本编程,这里推荐两本电子读物:
第一本相对来说更加平滑,适合零基础的人进行自学;
第二本相对来说更加全面,适合有一点基础的人进行自学;
另外,强烈建议你在学习了一定 shell 语法基础之后去找一个比较完善的编码规范进行阅读并严格按照规范进行脚本编辑,这里我个人推荐 Google Shell 风格指南。
无论是大的项目脚本还是小的工具脚本,严格的按照一个成熟的编码规范进行编辑能够帮助我们(在前期)更好的规划脚本以及(在后期)更快的 DEBUG。
注:本文中所有测试代码均为 zsh 输出结果
注2:本文中所有测试代码均以 Google Shell 风格指南 作为编码规范
bash -c help
命令查看关键字;使用一个已经定义的变量只需要在变量名前加美元符号($)即可,变量名两边的花括号({})可加可不加。
1 | test_name="test" |
注意无论您是否选择在变量名两边加入花括号请保持上下文编码规范的一致性。
使用 readonly 命令可以将变量定义为只读变量,只读变量的值不能被改变。
可以使用 unset 命令删除变量,被删除的变量不能再次使用,unset 不能删除只读变量。
shell 中存在三类变量:
1 | 定义格式:array_name=(value1 value2 ... valuen) |
注:这里仅罗列了集中常见用法,如果想要了解更多数组操作可以查看 余子越:shell数组与字典总结;
注2:该博文给出的特性本人并未全部测试,请先测试确定有效后再使用;
本地测试失败,暂不整理。
1 | first_name="test" |
在定义 shell 字符串变量时如果使用单引号包裹变量内容,则被包裹的部分会原样输出,如果使用双引号包裹变量内容,则在输出变量内容时会先解析变量内的变量、命令和转义字符。
1 | your_name="test" |
以上句式中:
welcome1 属于单引号字符串无法解析变量,
welcome2 属于字符串拼接,
welcome3 属于双引号解析字符串变量,
welcome4 属于字符串拼接。
1 | string="abcdefghijklmn" |
1 | ${n}:$0 表示命令本身,$1-$9 代表第 1 到第 9 个参数,10 以上加花括号,个人建议全部加花括号 |
注释符。如果一行脚本的开头是#(除了#!),那么代表这一行是注释,不会被执行。
命令分隔符。允许在同一行内放置两条或更多的命令。
空命令。它在 shell 中等价于 “NOP”(即no op,空操作)与 shell 内建命令 true 有同样的效果。它本身也是 Bash 的内建命令之一,返回值是 true(0)。
如果你学过 Python 的话,它很像 Python 中的 pass(个人感觉)。
在新的子 shell (环境)里执行使用分号(;)隔开的一组命令,且最后一个命令可以不用分号。
1 | 测试文件路径 /root/test.sh |
在当前 shell (环境)里执行使用分号(;)隔开的一组命令,最后一个命令也需要加分号,且左括号和第一个命令之间必须有空格(分隔符)。
代码块,又被称作内联组(inline group)。
它实际上创建了一个匿名函数(anonymous function),即没有名字的函数。
但是,不同于那些“标准”函数,代码块内的变量在脚本的其他部分仍旧是可见的。
1 | 测试文件路径 /root/test.sh |
比较推荐使用 $() 这种形式,理由有:
1 | a=`command1 \`command2\` ` |
但是 $() 不能支持全部 shell(但 bash 中是可以用的),而反引号(``)基本上可以在所有 unix shell 中使用。
(()) 的特性:与 let 命令类似,允许对算术表达式的扩展和求值,是 let 命令的简化形式。
(()) 单独使用时的一些作用:
1 | a=3 |
(()) 配合其他语句使用时的一些例子:
1 | for (( i = 0; i < 5; i++)); do |
$(()) 的作用:整数运算(不支持浮点数)
1 | $(()) 中支持 + - * / % & | ^ ! |
在 shell 中 [ expr ]
(注意:左右中括号和命令之间都有空格)等同于命令 test expr
,主要用于:数值判断、文件判断和字符串判断。
test 和 [] 中可用的比较运算符只有 = 和 !=,要比较大小则只能使用 test 3 -eq 4
或 [ 3 -eq 4 ]
这种形式
参数 | 功能 |
---|---|
A -eq B | 判断 A 是否等于 B |
A -ne B | 判断 A 是否不等于 B |
A -gt B | 判断 A 是否大于 B |
A -ge B | 判断 A 是否大于等于 B |
A -lt B | 判断 A 是否小于 B |
A -le B | 判断 A 是否小于等于 B |
注:A 和 B 为任意数值或数值变量
参数 | 功能 |
---|---|
-e filename | 判断文件是否存在 |
-r filename | 判断文件是否可读 |
-w filename | 判断文件是否可写 |
-x filename | 判断文件是否可执行 |
-s filename | 判断文件是否存在且至少有一个字符 |
-d filename | 判断文件是否存在且为目录文件 |
-f filename | 判断文件是否存在且为普通文件 |
-c filename | 判断文件是否存在且为字符型特殊文件 |
-b filename | 判断文件是否存在且为块特殊文件 |
注:filename 为完整(但可以是相对也可以是绝对)文件路径或文件路径变量
参数 | 功能 |
---|---|
stringA = stringB | 判断 stringA 是否等于 stringB |
stringA == stringB | 判断 stringA 是否等于 stringB |
stringA != stringB | 判断 stringA 是否不等于 stringB |
-z stringA | 判断 stringA 长度是否为零 |
-n stringA | 判断 stringA 长度是否不为零 |
注:stringA 和 stringB 代表任意字符串或字符串变量
[[]] 并非 shell 通用关键字,但大多数常用 shell 都支持,与 [] 相比,[[]] 更加常用。
[[]] 支持字符串的模式匹配,使用 == 操作符时还支持 shell 的正则表达式,字符串在比较的时候可以将等号右边的内容当做一个正则表达式的匹配模式,而不仅仅是一个字符串,例如 [[ hello == hell? ]]
的结果为 true,[[]] 中匹配字符串或通配符不需要引号。
在 if 语句中使用 [[]] 可以直接在判断语句中使用 && || 等操作符,但在 [] 中会报错,例如 if [[ ${a} == 1 && ${a} != 2 ]]
,如果使用 [] 则需写成 if [ ${a} -ne 1 ] && [ ${a} != 2 ]
或者 if [ ${a} -ne 1 -a ${a} != 2]
。
注:在 [[]] 中使用 < > 是基于字符串进行判断的,例如 [[ 321 > 1234 ]]
输出为真,因为按照字符串来判断,3 大于 1
在支持 [[]] 的 shell 中会把它内部的表达式当做一个单独的元素,并返回一个退出状态码(0 为真,1 为假)。
1 | !/bin/bash |
参数 | 说明 |
---|---|
-a | 后面跟一个变量,该变量会被认为是一个数组,通过 read 给其赋值,默认以空格为分隔符 |
-d | 后面跟一个标志符,作为结束的标志 |
-p | 后面根提示信息,在输入前打印提示信息 |
-e | 输入的时候打开自动补全功能 |
-n | 后面跟一个数字,定义输入文本长度 |
-r | 屏蔽 \,如果没有该选项 \ 会被认为是转义符,如果有的话会被认为是正常字符 |
-s | 安静模式,输入字符时不在屏幕上显示,常用于密码输入 |
-t | 后面跟秒数,定义输入字符的等待时间 |
-u | 后面跟 fd,从文件描述符中读入 |
1 | 基础语法格式 |
1 | 基本语法格式 |
1 | if [ ${1} -ge 60 ]; then |
1 | 基本语法格式 |
1 | 实例 |
循环是当循环控制条件为真时,一系列命令迭代执行的代码块。
1 | my_array=("test" 2 "abc" ${a}) |
while 循环结构会在循环顶部检测循环条件,若循环条件为真(退出状态为 0)则循环持续进行。
与 for 循环 不同的是,while 循环是在不知道循环次数的情况下使用的。
1 | sum=0 |
与 while 循环相反,until 循环测试其顶部的循环条件,直到其中的条件为真时停止。
1 | sum=0 |
本来就是想简单的整理一下 shell 的常用基础语法,没想到越整理接触到的概念越多,导致我要往这篇文章里塞入的东西也越来越多,当你读完以上文章内容,不过是接触到了 shell 语法的冰山一角。它还有很多基础语法是我没有整理到位的,也有很多进阶应用是这篇文章不应涉及但却十分重要的,毕竟 shell 非常接近 Unix 系统内核,因此如果你想要系统的学习 shell 还请根据自身情况参考我在 前言 中给出的书籍进行学习。
本文主要描述了何为寻址,寻址能力的计算以及 8086 处理器的寻址方式有哪些。
内存中每一个字节(8bit)都有一个对应的内存地址,CPU 去访问某一具体内存地址的过程称为寻址。
CPU 的寻址能力一般使用寻址空间来表示,寻址空间的大小决定了 CPU 可支持的最大内存容量,以字节为单位。寻址空间的大小由地址总线的地址寄存器宽度(位数)决定,假设地址总线位数为 N 位,则寻址空间为 2 的 N 次方字节(因为计算机使用的是二进制所以是 2 的 N 次方)。
8086 处理器有 20 位地址总线,可传送 20 位的地址,寻址空间为 1M。
而 8086 处理器是 16 位结构的处理器即 8086 内部的寄存器位数为 16 位,如果按照这个数据处理能力 8086 只能发送出 16 位的地址,表现出的寻址能力只有 64 KB。
为了解决上述问题,在 8086 处理器内部采用了一种使用两个 16 位地址(段地址:偏移地址)合成一个 20 位物理地址的方案。
具体计算公式为:物理地址 = 段地址左移四位 + 偏移地址。
在汇编语言中,一般的指令格式为:指令代码 目的操作数,源操作数。
目的操作数和源操作数统称为操作数,而寻址方式的主要表现形式就是体现在两个操作数的表现形式上。
8086 处理器有七种基本寻址方式:
指令执行时,操作数位于寄存器中,可以直接从寄存器中获取。
1 | mov ax,cx ; 此条指令中目的操作数和源操作数使用的都是寄存器寻址 |
立即数寻址又称为立即寻址,指的是操作数为立即数的寻址方式。
所谓立即数指的是直接包含在指令中且紧跟在操作码后可以立即从指令中获取的操作数。
在立即寻址中立即数可以是 8 位的,也可以是 16 位的(注意我们的大前提是在 8086 处理器下)。这种寻址方式主要用于给寄存器或存储单元赋初始值,立即寻址是这七种基本寻址方式中速度最快的寻址方式。
注:在实例代码中会有一个特殊的立即寻址例子。
1 | mov ax,oxf000 ; 此条指令中目的操作数使用的是寄存器寻址,源操作数使用的是立即寻址 |
此前介绍的两种寻址方式:寄存器寻址和立即数寻址均未涉及到内存地址,都是在指令层面或更高级的存储空间(寄存器)层面的数据提取和存储。
寄存器寻址的操作数位于寄存器中,立即寻址的操作数位于指令中,是指令的一部分。这是两种速度比较快的寻址方式,但它们也有局限性:一方面,我们不可能总是知道要操作的数是多少,因此也就不可能总是在指令上使用立即数;另一方面,寄存器的数量有限,不可能总指望在寄存器之间来回传递数据。
而内存恰巧拥有较大的容量,所以在指令中使用内存地址来表明要操作的内存中的数据是比较理想的方案。下面我们要介绍的五中寻址方式才是真正的在内存汇总寻找所需数据的寻址方式,它们统称为内存寻址。
在正式介绍内存寻址之前需要先理解一个概念:有效地址,即偏移地址。有效地址是一个 16 位的无符号数,表示操作数所在内存单元到段首的距离。
有效地址(Effective Address,EA)= 偏移量(disp)+ 基址(base)+ 变址(index)。
偏移量:存放在指令中的数,但它不是一个立即数,而是一个地址,可以用变量或标号表示。
基址:存放在基址寄存器(BX,BP)中,有效地址的基址部分。
变址:存放在变址寄存器(SI,DI)中,有效地址的变址部分。
三者都是可选的,但必须存在一个。
操作数所表示的有效地址仅包含偏移量一种成分,即有效地址 = 偏移量。
1 | mov ax, [0x5c0f] ; 目的操作数使用的是寄存器寻址,源操作数使用的是直接寻址,地址为:ds:0x5c0f |
操作数所表示的有效地址存放在 BX BP SI DI 中的某一个寄存器中。此种寻址方式与寄存器寻址的区别在于:在寄存器寻址方式下寄存器存储的是待操作数据本身,而在本寻址方式下寄存器存储的是待操作数据所在的内存地址段内偏移量。
1 | mov ax, [bx] ; 目的操作数使用的是寄存器寻址,源操作数使用的是寄存器间接寻址,地址为:ds:bx |
操作数所表示的有效地址是 BX BP SI DI 中的某一个寄存器的内容和给出的偏移量之和(差)。
1 | mov ax, [bx + 0x0030] ; 目的操作数使用的是寄存器寻址,源操作数使用的是寄存器相对寻址,地址为:es:bx+0x0030 |
操作数所表示的有效地址是基址寄存器(BX,BP)和变址寄存器(SI,DI)所表示的内容之和。
1 | mov ax, [bx+si] ; 目的操作数使用的是寄存器寻址,源操作数使用的是基址变址寻址,地址为:es:bx+si |
相对基址变址寻址是在基址变址寻址的基础上又多增加了一个偏移量的值。
1 | mov ax, [bx+si+0x0030] ; 目的操作数使用的是寄存器寻址,源操作数使用的是基址变址寻址,地址为:es:bx+si+0x0030 |
机器指令是用二进制代码表示的 CPU 能够直接识别和执行的一种指令,不同的 CPU 架构有不同的机器指令集。汇编指令是将机器指令对应到便于记忆和书写的字符串(注意并非一一对应,同一汇编器可能存在多个汇编指令对应一个机器指令的情况),汇编指令编写完成后通过汇编器将其翻译成机器指令供 CPU 执行。
不同汇编器针对同一机器指令可以有不同的汇编指令表达方式,只要汇编器最终能够正确无误地翻译就可以。 不同的汇编器对应不同的汇编指令格式,不同的汇编指令格式衍生出不同的汇编指令语法。没有一种汇编器可以将所有的汇编语法都正确地翻译成机器指令,因此,随着计算机的发展,不同厂家形成了自家的汇编语言体系并拥有自己的汇编器。
常见的汇编器有:GNU Assembler(GAS) | Microsoft Macro Assembler(MASM) | Netwide Assembler(NASM) | Flat Assembler(FASM) 等。GAS 使用 AT&T 汇编语法,MASM 使用 Intel 汇编语法,NASM 使用的汇编语法和 Intel 汇编语法类似但要更简单一些。
注:本文以 NASM 使用的汇编语法为例
NASM 的基本句型可以由四部分组成:label: instruction operand(s) ; comment
。
理论上来说上面的四个部分都是可选的,但至少存在其中一个部分,一个语句可以没有指令而只存在一个标签。
而标签后的冒号也是可以省略的。
NASM 语法对空格数量没有要求和限制,可以在任何两个部分的间隙添加任意数量的空格(至少一个用来区分两个部分)。
在 NASM 中使用反斜杠(\)作为行的延续符,如果一行以反斜杠结束,则当前行的下一行被认为是当前行的延续。
标签可以使用字母、数字、下划线(_)、美元符($)、井号(#)、艾特(@)、波浪线(~)、英文句号(.)、英文问号(?),其中字母、下划线(_)、英文句号(.)和英文问号(?)。其中以英文句号(.)开头有特殊的含义(详情见下文)。
在 NASM 中所以英文句号(.)开头的的标签会被视为局部标签,所有局部标签会被认为与上一个非局部标签有关联。
1 | label1: |
在上述代码片段中,所有的 jne 指令都会跳转到上面与之相邻的 .loop 标签,因为 .loop 标签的定义形式是一种局部标签定义形式,因此两个 .loop 标签分别会与该标签上面最近的全局标签产生关联。而在为特别指定的情况下局部标签只能在与其相关的全局标签下生效,但也可以通过「全局标签.局部标签」的形式进行调用。
NASM 使用 C 风格的转义字符,在反斜杠后跟转义码,转义码包括:字符转义码、八进制转义码、十六进制转义码,且转移字符需要使用反引号引用:
1 | db `\x61` ; 等同于 db a |
注:反引号也可以用来定义普通字符串
有的地方称为「索引操作符」,表示一种间接取操作数的方式,即取括号内内存地址对应的操作数,类似 C 语言的指针概念。
括号中一般存放的是一个内存地址,可以是使用寄存器表示的内存地址,可以是使用标记表示的内存地址,也可以是直接用操作数表示的内存地址。
1 | mov ax, [var] |
1 | $ 表示经过 NASM 编译后当前指令位置; |
ptr -> pointer 即指针的缩写,用来临时指定类型,可以类比为 C 语言中的强制类型转换。
1 | mov ax, bx ; 由于寄存器 ax 和 bx 都是 word 型,所以没有必要加 word |
伪指令不是真正的指令,而是为了方便 NASM 汇编器而存在,但是它们的地位与真正的指令相同。
通常存在于操作指令和操作数之间,用来表明操作指令使用的操作数单位大小。
指令 | 代表数据大小(位) |
---|---|
byte | 8 |
word | 16 |
dword | 32 |
qword | 64 |
tword | 80 |
oword | 128 |
yword | 256 |
一系列用来声明并初始化数据的伪指令:
指令 | 功能 | 备注 |
---|---|---|
db | 定义字节数据 | |
dw | 定义字数据 | |
dd | 定义双字数据 | 可以定义单精度浮点数 |
dq | 定义四字数据 | 可以定义双精度浮点数 |
dt | 定义十字数据 | 可以定义扩展精度浮点数 |
do | 定义 oword | 可以定义四精度浮点数 |
dy | 定义 yword | 可以定义 ymm 数据 |
注:dt do dy 不接受整型数值。
相比于 db 家族 resb 家族的指令只会在编译阶段声明一个未初始化的出处空间但并不会为其设置初始值。
resb: reserve byte
指令 | 功能 |
---|---|
resb | 以字节为单位声明一段未初始化数据 |
resw | 以字为单位声明一段未初始化数据 |
resd | 以双字节为单位声明一段未初始化数据 |
resq | 以四字为单位声明一段未初始化数据 |
rest | 以十字为单位声明一段未初始化数据 |
reso | 以 oword 为单位声明一段未初始化数据 |
resy | 以 yword 为单位声明一段未初始化数据 |
NASM 提供了一种包含二进制文件的方法,即使用 incbin 伪指令,此伪指令的作用是包含 graphics 以及 sound 这类数据文件。
equ 伪指令用来为某个标识符赋值一个整型常量,作用类似于 C 语言的 #define:
1 | a equ 0 ; 正确 |
在例子中,b 和 c 存储的是字符串对应的 ASCII 码,而因为整型常量最大是 quadword(8 bytes),因此 c 对应的字符串会被自动截断为 ‘abcd’。而 d 存储的是非整型值,因此会报错。
用来重复指令(或伪指令),下面是一个比较经典的例子:
1 | ; 用于填充引导代码 |
NASM 顶一个两个操作数符来定义 Unicode 字符串:
1 | dw __utf16__('你好世界') |
在 NASM 中 SECTION 和 SEGMENT 指令是相同的同义词,可以改变所写代码被分配到哪一个 section 中。在一些 object file 中,section 的数量是固定的;在其他格式中,用户可以根据自己的需求来自定义 section。
本章节以 NASM 的 bin output formats 为例讲解多 section 用法
NASM 支持标准的 .data .text .bss,编译后程序文件中内存地址的顺序是 .text .data 用户 section,同名 section 编译后会放在同一块连续的内存上。
section 特性:
align=
或 start=
字句在指定对齐字节,区别是 align 只接受 2 的 N 次幂,而 start 可以接受任意整数值;vstart=
字句定义一个虚拟起始地址,它将被用于计算该 section 内的所有内存引用;follows=<section>
或 vfollows=<section>
字句来进行排序;section.<secname>.start
用来获取该 section 起始地址;拓展:
progbits:程序内容,包含代码、数据、调试相关信息;
nobits:和PROGBITS类似,唯一不同的是在文件中不占空间,对应的进行内存空间是加载的时候申请的;
1 | db '11' |
算数运算指令主要包括二进制的定点、浮点的加减乘除运算指令;求反、求补、加一、减一、比较指令;十进制加减运算指令等;不同计算机对算数运算指令的支持有很大的差别。
add <目的操作数>, <源操作数>
影响标志位:OF,SF,ZF,AF,PF,CF。
将源操作数加到目的操作数上(结果存储在目的操作数上)。
源操作数和目的操作数类型必须一致,且两者不能同时使用存储器操作数。
adc <目的操作数>, <源操作数>
带进位加法指令,与 ADD 基本相同,区别在于执行指令前会将标志位 CF 的值加到目的操作数上,多用于多字节加法运算。
1 | mov DX, 0x2FFF |
inc <目的操作数>
加一指令,将目的操作数加一。
sub <目的操作数>, <源操作数>
用目的操作数减去源操作数(结果存储在目的操作数上)。
源操作数和目的操作数类型必须一致,且两者不能同时使用存储器操作数。
dec <目的操作数>
减一指令,将目的操作数减一。
neg <目的操作数>
用于求目的操作数的补码(取反再加一)。
可以通过寄存器或内存单元向 neg 指令传送目的操作数。
用于比较源操作数和目的操作数。
cmp 指令类似 sub 指令,只是不保存计算结果但对标志寄存器产生影响,其他指令可以通过识别这些被影响的标志寄存器位来得知比较结果。
在指令中,目的操作数是被测量的对象,源操作数则作为测量的基准。指令使用目的操作数减去源操作数并在不保存结果的情况下对标志寄存器产生影响。
会被产生影响的标志位有:溢出 符号 零 进位 辅助进位 奇偶 | OF SF ZF CF AF PF
1 | mov ax, 8 |
mul <源操作数>
无符号乘法运算,结果为整数。
mul 指令可以通过寄存器或内存单元接受一个 8 位或 16 位的乘数:
如果乘数是 8 位的:那么源操作数与寄存器 AL 中的 8 位数相乘得到的结果存储在 AX 中;
如果乘数是 16 位的:那么源操作数与寄存器 AX 中的 16 位数相乘得到的结果存储在 DX:AX 中;
mul 执行后,如果结果的高位全是零则 OF 和 CF 清零,否则置一,对 SF ZF AF 和 PF 标志位影响未定义。
div <源操作数>
用于进行无符号除法运算,结果为整数。
除数作为源操作数传入,存储在寄存器或内存单元中。
被除数默认存放在 AX(16 位以内)或 AX 和 DX(32 位,DX 存放高位,AX 存放低位)中。
div 操作的结果分为商和余数两部分。
如果除数是 8 位的,那么结果中的商存储在 AL 中,余数存储在 AH 中。
如果除数是 16 位的,那么结果中的商存储在 AX 中,余数存储在 DX 中。
执行条件:
cbw
将寄存器 AL 中数据的最高位扩展到 AH 中,若 AL 中最高位为 0,则 AH 被设置为 00H,若 AL 中的最高位为 1,则 AH 被设置为 FFH。
cwd
将寄存器 AX 中数据的最高位拓展到 DX中,若 AX 中最高位为 0,则 DX 被设置为 0000H,若 AX 中的最高位为 1,则 DX 被设置为 FFFFH。
and <目的操作数>, <源操作数>
将目的操作数和源操作数进行按位逻辑与运算,结果存储在目的操作数。
or <目的操作数>, <源操作数>
将目的操作数和源操作数进行按位逻辑或运算,结果存储在目的操作数。
xor <目的操作数>, <源操作数>
将目的操作数和源操作数进行按位逻辑异或运算,结果存储在目的操作数。
not <目的操作数>
将目的操作数按位取反,结果存储在目的操作数。
test <目的操作数>, <源操作数>
将目的操作数和源操作数进行按位逻辑与运算,不存储结果。
1 | shl <目的操作数> <源操作数(移位次数)> |
shl(逻辑左移)和 sal(算数左移)的实际效果完全相同。
两者的作用是:将目的操作数向左移位源操作数个位数,最低位用 0 填充,最高位移入进位标志位(CF)。
1 | shr <目的操作数> <源操作数(移位次数)> |
shr(逻辑右移)和 sar(算数右移)有所不同:
shr:高位用 0 填充,低位移入进位标志位(CF)。
sar:高位用符号位填充,低位移入进位标志位(CF)。
rol <目的操作数>, <源操作数(移位次数)>
rol(Rotate Left):循环左移指令,将目的操作数左移指定次数,最高位送入最低位和进位标志位(CF)。
ror <目的操作数>, <源操作数(移位次数)>
ror(Rotate Right):循环右移指令,将目的操作数右移指定次数,最低位送入最高位和进位标志位(CF)。
1 | movsw ; 执行一次 |
串传送,从源地址向目的地址批量传送数据。
16 位模式下源地址是 DS:SI
,目的地址是 ES:DI
。
32 位模式下源地址是 DS:ESI
,目的地址是 ES:EDI
。
根据传送数据大小又分为 movsb, movsw, movsd,分别对应传送一个字节,一个字,一个双字。
movs 命令可以使用重复执行,方向标志位 DF 决定了 SI 和 DI 在单次操作后是增加(0)还是减少(1)
每次变动的大小与具体执行命令有关:movsb -> 1B | movsw -> 2B | movsd -> 4B
1 | rep movsw |
重复前缀指令,不能单独使用,可以用来重复执行跟在后面的指令,重复次数由 CX 控制(每次重复 CX 减一,知道 CX 值为零停止)。
可以修改 IP 或同时修改 CS 和 IP 寄存器内容的指令统称为转移指令。可以通俗理解为:转移指令就是可以控制 CPU 下一步执行内存中哪一处指令的指令。
在 8086 中按照转移行为可分为:
按照功能不同,转移指令又可细分为一下几种:
描述:无条件转移指令可以控制 CPU 下一步执行代码段(CS)中任意内存地址对应的指令
1 | start: mov ax,offset start ; 相当于 mov ax,0 |
操作符,由编译器处理,功能是获取标签的偏移地址
转移地址可以在指令、内存或寄存器中指出。可以只修改 IP,也可以同时修改 CS 和 IP
使用 jmp 指令时需要提供两种信息:
语法:jmp short <标签>
作用:转移到标签处执行指令
描述:这种格式的 jmp 指令实现的是段内短转移,short 为短转移标志
原理:ip = ip + 8 位位移 | 8 位位移 = 标签地址 - jmp 指令后第一个字节的地址
此指令形式是针对当前指令所在位置(即当前 IP)进行跳转的,且 8 位位移范围是 -128~127,由编译程序在编译时计算
示例:
1 | start: |
语法:jmp near ptr <标签>
作用:转移到标签处执行指令
描述:这种格式的 jmp 指令实现的是段内近转移,near 为近转移标志
原理:ip = ip + 16 位位移 | 16 位位移 = 标签地址 - jmp 指令后的第一个字节地址
此指令也是针对当前指令所在位置(即当前 IP)进行跳转的,且 16 位位移范围是 -32768~32767,由编译程序在编译时计算
语法:jmp far ptr <标签标签>
作用:转移到标签处执行命令
描述:这种格式的 jmp 指令实现的是段间转移(即远转移),far ptr 为远转移标志
原理:cs = 标签所在段的段地址 | ip = 标签所在段中的偏移 | 高位存储段地址,低位存储偏移地址
语法:jmp word ptr <[内存单元地址]>
作用:转移到目标内存地址所存储的地址处执行指令
描述:这种格式的 jmp 指令实现的是段内转移,word ptr 是转移标志
原理:ip = 内存地址所存储的内容
语法:jmp dword ptr <[内存单元地址]>
作用:在内存单元地址处存放两个字,高地址存放转移的目的段地址,低地址存放转移的目的偏移地址
描述:这种格式的 jmp 指令实现的是段间转移(即远转移),dword ptr 为远转移标志
原理:cs = 内存单元地址 + 2 所存储的内容 | ip = 内存单元地址存储的内容
示例:
1 | mov ax,0123H |
语法:jmp <16位寄存器>
作用:转移到目标寄存器所存储的地址处执行指令
描述:这种格式的 jmp 指令实现的是段内转移
原理:ip = 16位寄存器内容
语法:jmp <[段地址:偏移地址]>
作用:转移到目标地址处执行命令
描述:这种格式的 jmp 执行实现的是段间转移
原理:cs = 段地址 | ip = 偏移地址
jz 和 je 意义相同,只是写法不同。
jnz 和 jne 意义相同,只是写法不同。
jz:如果标志位 ZF = 1,则跳转到指定地址。
jnz:如果标志位 ZF = 0,则跳转到指定地址。
ZF:零标志位,相关指令执行后结果是否为零 | 0 -> 否 | 1 -> 是。
jc:如果标志位 CF = 1,则跳转到指定地址。
jnc:如果标志位 CF = 0,则跳转到指定地址。
CF:进位标志位,相关指令执行后是否产生了进位或借位 | 0 -> 没产生 | 1 -> 产生了。
jp 和 jpe 意义相同,只是写法不同。
jnp 和 jpo 意义相同,只是写法不同。
jp:如果标志位 PF = 1,则跳转到指定地址。
jnp:如果标志位 PF = 0,则跳转到指定地址。
PF:奇偶标志位,相关指令执行后结果中为 1 的比特的个数是否为偶数 | 0 -> 奇 | 1 -> 偶。
js:如果标志位 SF = 1,则跳转到指定地址。
jns:如果标志位 SF = 0,则跳转到指定地址。
SF:符号标志位,相关指令执行后结果是否为负数 | 0 -> 非负数 | 1 -> 负数。
jo:如果标志位 OF = 1,则跳转到指定地址。
jnp:如果标志位 OF = 0,则跳转到指定地址。
OF:溢出标志位,有符号运算结果是否产生溢出 | 0 -> 否 | 1 -> 是。
处理器控制指令包括标志操作指令和 CPU 控制指令
stc:将 CF 设置为 1
clc:将 CF 设置为 0
cmc:将 CF 取反
CF:进位标志,计算中是否产生了进位或借位
std:将 DF 设置为 1
cld:将 DF 设置为 0
DF:串处理指令中,每次操作后 SI 或 DI 自增(0)还是自减(1)
sti:将 IF 设置为 1
cli:将 IF 设置为 0
IF:中断允许标志,CPU 是否能响应外部课评比中断请求
数据是信息的载体,是描述客观事物的数、字符以及所有能输入到计算机中并被计算机程序识别和处理的符合的集合。
数据大致可分为两类,一类是数值性数据,包括整数、浮点数、复数、双精度数等,主要用于工程和科学计算,以及商业事务处理;另一类是非数值性数据,主要包括字符和字符串,以及文字、图形、图像、语音等数据。
数据元素是数据的基本单位,通常作为一个整体进行考虑和处理。一个数据元素可以有若干个数据项组成,数据项是数据元素的不可分割的最小单位。例如:学生记录作为一个数据元素,它由学号、姓名、性别等数据项组成。
数据对象是具有相同性质的数据元素的集合,是数据的一个子集。
数据类型是一个值的集合和定义在此集合上一组操作的总称。
一个数学模型以及定义在该模型上的一组操作。
抽象数据类型的定义仅取决于它的一组逻辑特性,而与其在计算机内部如何表示和实现无关,即不论其内部结构如何变化,只要它的数学特性不变,都不影响其外部的使用。通常用数据对象(D)、 数据关系(S)、基本操作集(P)这样的三元组来表示抽象数据类型。
数据结构包括三方面的内容:逻辑结构、存储结构和数据的运算。数据的逻辑结构和存储结构是密不可分的两个方面,一个算法的设计取决于所选定的逻辑结构,而算法的实现依赖于所釆用的存储结构。
数据结构是由某一数据元素的集合和该集合中数据元素之间的关系组成的。
Data_Structure = {D, R}
D 是某一数据元素的集合,R 是该集合中所有数据元素之间的关系的有限集合。
有关数据结构的讨论主要涉及数据元素之间的关系,不涉及数据元素本身的内容。
数据的逻辑结构是指数据元素之间的逻辑关系,即从逻辑关系上描述数据,与数据的存储无关。
依据元素之间关系的不同,数据的逻辑结构分为两大类:线性结构和非线性结构。
线性结构:数据元素之间存在一对一的关系
树形结构:数据元素之间存在一对多的关系
图形结构:数据元素之间存在多对多的关系
集合结构:数据元素属于同一个集合
数据的存储结构是指数据结构在计算机中的具体表示(又称映像),也成物理结构。包括数据元素的表示和数据元素间关系的表示。数据的存储结构是数据的逻辑结构用计算机语言实现的。它依赖于计算机语言。主要有如下四种结构:
施加再数据上的运算包括运算的定义和实现。运算的定义时针对逻辑结构的,之处运算的功能;运算的实现是针对存储结构的,之处运算的具体操作步骤。
算法(algorithm)是对特定问题求解步骤的一种描述,它是指令的有限序列,其中每一条指令表示一个或多个操作。
算法效率的度量可分为事前估计和后期测试。
一个语句的频度是指该语句在算法中被重复执行的次数。算法中所有语句的频度之和记作 T(n)
,它是该算法问题规模 n
的函数,时间复杂度主要分析 T(n)
的数量级。算法中的基本运算(最深层循环内的语句)的频度与 T(n)
同数量级,所以通常釆用算法中基本运算的频度 f(n)
来分析算法的时间复杂度。因此,算法的时间复杂度也记为:T(n)=O(f(n))
上式中 O
的含义是 T(n)
的数量级,其严格的数学定义是:若 T(n)
和 f(n)
是定义在正整数集合上的两个函数,则存在正常数 C
和 n_0
,使得当 n >= n_0
时,都满足 0 <= T(n) <= C * f(n)
。
注意:取 f(n)
中随 n
增长最快的项将其系数置为 1 作为时间复杂度的度量。例如,fi(n) = a * n^3 + b * n^2 + c * n
,则其时间复杂度为 O(n^3)
。
算法的时间复杂度不仅依赖于问题的规模 n
,也取决于待输入数据的性质(如输入数据元素的初始状态)。
一般总是考虑在最坏情况下的时间复杂度,以保证算法的运行时间不会比它更长。
在分析一个程序的时间复杂性时,有以下两条规则:
T(n) = T1(n) + T2(n) = O(f(n)) + O(g(n)) = O(max(f(n), g(n)))
T(n) = T1(n) * T2(n) = O(f(n)) * O(g(n)) = O( f(n) * g(n) )
常见的渐近时间复杂度有:O(1)<O(log2n)<O(n)<O(nlog2n)<O(n^2)<O(n^3)<O(2^n)<O(n!)<O(n^n)
算法的空间复杂度 S(n)
,定义为该算法所耗费的存储空间,它是问题规模 n
的函数。渐近空间复杂度也常简称为空间复杂度,记作 S(n)=O(g(n))
。
一个上机程序除了需要存储空间来存放本身所用指令、常数、变量和输入数据外,也需要一些对数据进行操作的工作单元和存储一些为实现计算所需信息的辅助空间,若输入数据所占空间只取决于问题本身,和算法无关,则只需分析除输入和程序之外的额外空间。
算法原地工作是指算法所需辅助空间是常量,即 O(1)
。
如果你也想阅读 WSGI 相关的 PEP 规范,建议直接阅读 PEP 3333,因为 PEP 3333 对 PEP 333 是向下兼容的,也可以说 PEP 3333 是对 PEP 333 的补充。
This document specifies a proposed standard interface between web servers and Python web applications or frameworks, to promote web application portability across a variety of web servers.
本文档详细描述了一个建议用在 Web 服务器和 Python Web 应用或框架之间的标准接口,以提升 Web 应用在各类 Web 服务器之间的可移植性。
from PEP 3333
从 PEP 3333 的这段总结来看,WSGI 就是一个 Python 官方建议用在 Web 服务器和 Python Web 应用框架之间的标准接口。
首先,什么是服务器(server)?
一般来说,server 有两重意思:
作为开发者,一般提到 server 时指的都是后者,即一个长时间运行的软件程序。
所以,什么是 Web Server?
通俗的来讲 Web Server 就是一个提供 Web 服务的应用程序。
常见的符合 WSGI 规范的 Web Server 有 uWSGI、gunicorn 等等。
Web 框架在如今是比较常见的,比较知名的 Python Web 框架有:Django、Flask、Pyramid等等。反倒是 Web 应用不太常见,(个人理解)一般情况下只有在本地测试的时候会写一些简单的 Python Web 应用,平时的开发大多还是使用开源(或公司内部)的 Web 框架。
作为一个近两年刚接触到 Python Web 编程的新手,在日常的编程过程中完全没有见过所谓的 WSGI,但是我依然可以写好一个完整的 Web 应用,这是为什么?WSGI 有存在的必要嘛?
答案肯定是:有存在的必要。
首先解释一下为什么我在过去两年的过程中没有见过 WSGI 却依旧可以进行 Web 编程:因为现在的大多数框架都已经帮我们将 WSGI 标准封装在框架底层。甚至,我用的 Django REST Framework 框架连 HTTP Request 和 HTTP Response 都帮我封装好了。所以,就算我完全不了解 WSGI 这种偏底层的协议也能够进行日常的 Web 开发。
那 WSGI 到底解决了什么问题?这个在 PEP 3333 中有详细的解释,简单的说一下我的理解:在 WSGI 诞生之前,就已经存在了大量使用 Python 编写的 Web 应用框架,相应的也存在很多 Web 服务器。但是,各个 Python Web 框架和 Python Web 服务器之间不能互相兼容。夸张一点说,在当时如果想要开发一个 Web 框架说不定还得单独为这个框架开发一个 Web 服务器(而且这个服务器别的框架还不能用)。为了解决这一现象 Python 社区提交了 PEP 333,正式提出了 WSGI 这个概念。
简单的理解:只要是兼容 WSGI 的 Web 服务器和 Web 框架就能配套使用。开发服务器的程序员只需要考虑在兼容 WSGI 的情况下如何更好的提升服务器程序的性能;开发框架的程序员只需要考虑在兼容 WSGI 的情况下如何适应尽可能多业务开发逻辑(以上只是举例并非真的这样)。
WSGI 解放了 Web 开发者的精力让他们可以专注于自己需要关注的事情。
注:为了简练而写成了 WSGI 做了什么事情,实际上 WSGI 只是一个规范并不是实际的代码,准确的来说应该是「符合 WSGI 规范的 Web 体系做了什么事情?」
上面已经提到,WSGI 通过规范化 Web 框架和 Web 服务器之间的接口,让兼容了 WSGI 的框架和服务器能够自由组合使用……
所以,WSGI 究竟做了什么,让一切变得如此简单?
在 PEP 3333 中对 WSGI 进行了一段简单的概述,这里我结合看过的 一篇博文 进行简单的概括:
(简单来说)WSGI 将 Web 分成了三个部分,从上到下分别是:Application/Framework, Middleware 和 Server/Grageway,各个部分之间高度解耦尽可能的做到不互相依赖。
Middleware 属于三个部分中最为特别的一个,对于 Server 他是一个 Application,对于 Application 它是一个 Server。通俗的来说就是 Middleware 面对 Server 时能够展现出 Application 应有的特性,而面对 Application 时能够展现出 Server 应有的特性,由于这一特点 Middleware 在整个协议中起到了承上启下的功能。在现实开发过程中,还可以通过嵌套 Middleware 以实现更强大的功能。
通过上一小节能够大概的了解到 WSGI 在一次完整的请求中究竟做了什么。下面再来介绍一下一个完整的 WSGI Web 体系是如何工作的。
为了方便展示先来构建一个符合 WSGI 规范的 Python Web 项目示例:
注:示例基于 Python3
1 | # 本示例代码改自参考文章 5: |
1 | # /path_to_code/middleware.py |
1 | # /path_to_code/application.py |
1 | # /path_to_code/run.py |
将四段代码分别复制到同一目录的四个文件(如果没有按照示例给出的命名记得更改一下 run 模块中相应的 import 的模块名)中。
注:以下操作默认你完全按照示例代码中给出的命名进行文件命名
python /path_to_code/run.py
127.0.0.1:8888
查看效果curl -v http://127.0.0.1:8888
查看完整输出curl -v https://baidu.com
的输出查看区别上面我根据 WSGI 协议编写了三个文件(模块):server.py middleware.py application.py,分别对应 WSGI 里 server middleware application 这三个概念。然后通过 run.py 引入三个模块组成了一个完整的 server-middleware-application Web 程序并监听本地 8888 端口。
通过 run.py 中的代码我们能够清晰的看到一个 WSGI 类型的 Web 程序的运行流程:
server.__init__
方法)server.set_app
方法)server.server_forever
方法)通过 server.py 中的代码能够清晰的看到一个 WSGI 类型的 Web 程序是如何处理 HTTP 请求的:
server_forever
监听到客户端请求并记录请求信息handle_one_request
方法处理此请求parse_request
方法将请求数据解析成所需格式get_environ
方法利用现有数据构造环境变量字典finish_response
方法构造一个可迭代的响应对象返回给客户端并结束本次请求通过 middleware.py 中的代码就能够理解一个 WSGI 中间件是如何工作的:
__init__
方法中接收一个 application 将自己伪装成一个 server__call__
方法中接收 environ 和 start_response 参数将自己伪装成一个 application至于 application.py 在这里就真的只是一个简单的单文件 WSGI 应用。当然也可以尝试用写好的 server.py 和 middleware.py 对接像 Django 这样的框架,但需要对代码做一些修改,这里就不展开讨论了,有兴趣可以自己尝试。
在运行 run.py 之后使用浏览器浏览 127.0.0.1:8888
并查看结果如下:
通过控制台可以清晰地看到响应头和响应主体的内容是符合我们预期的
通过 curl http://127.0.0.1:8888
可以看到响应主体:
通过 curl -v http://127.0.0.1:8888
可以看到详细的请求和响应内容:
通过 curl -v https://baidu.com
获取百度首页的响应内容以作比较:
可以看到目前浏览网页常用的正常请求要比自己构建的测试示例要复杂的多,这也是为什么经常使用 Web 框架而非单文件应用来处理这些请求的原因。
PEP 3333 我只读到了 Buffering and Streaming 章节,并且没能很好的理解此章节所描述的东西,因此在下面的细节分析中大都是此章节之前的一些内容。
可迭代对象(callable)和可迭代对象(iterable)在 PEP 3333 中最常见的两个词汇,在 WSGI 规范中它们分别代表:实现了 __call__
的对象和实现了 __iter__
的对象。
这是一组比较基础的概念:
Python3 中字符串的默认类型是 str,在内存中以 Unicode 表示。如果要在网络中传输或保存为磁盘文件,需要将 str 转换为 bytes 类型。
Python3 里面的 str 是在内存中对文本数据进行使用的,bytes 是对二进制数据使用的。
str 可以 encode 为 bytes,但是 bytes 不一定可以 decode 为 tr。实际上
bytes.decode(‘latin1’)
可以称为 str,也就是说 decode 使用的编码决定了decode()
的成败,同样的,UTF-8 编码的 bytes 字符串用 GBK 去decode()
也会出错。bytes一般来自网络读取的数据、从二进制文件(图片等)读取的数据、以二进制模式读取的文本文件(.txt, .html, .py, .cpp等)
WSGI 中规定了两种 String:
在 PEP 3333 中有对这部分的详细说明。
了解了以上基础概念之后再具体的看一下 WSGI 的三个主要组成部件:
application(environ, start_response)
。而且这两个参数只能以位置参数的形式被传入。start_response(status, response_headers, exc_info=None)
。"200 OK"
__iter__
的对象。无论如何,application 必须返回一个能够产生零个或多个字符串 iterable。len(iterable)
能够被成功执行(这里的 iterable 指的是第 10 条中的 iterable)则其返回的必须是一个 server 能够信赖的结果。也就是说 application 返回的 iterable 如果提供了一个有效的 __len__
方法就必须能够获得准确值。可以参考我的开源库 read-python 中 practices/for_wsgiref 目录下的 server.py 文件。
在这个文件中我提取了 Python wsgiref 官方库的必要代码汇聚成一个文件实现了一个和 wsgiref.WSGIServer
大致同样功能的 WSGIServer
类。
Python wsgiref 官方库对 WSGI 规范的实现更加抽象,加上一些历史原因使得代码分布在多个官方库中,我在抽离代码的过程中学到了很多但是同样也产生了很多困惑,我在源码中使用 TODO 疑惑 XXX
的形式将我的困惑表达出来了,如果你感兴趣并且恰好知道解决我疑惑的方法,欢迎直接给我的代码仓库提交 Issues。
1 | OS=`uname -s` |
在 Linux
系统中有一个记录 OS 的发行版本的 os-release
文件,位置在 /etc/os-release
。可以利用 source /etc/os-release
将文件中的 key-value
数据导入到上下文中,然后通过不同系统 ID
值不同的特性进行区分。
1 | source /etc/os-release |
echo
命令用于在 shell
中打印 shell
变量的值,或者直接输出指定的字符串。
语法:echo [SHORT-OPTION]... [STRING]...
-n
:不输出换行-e
:开启对反斜线转移的解释-E
:取消对反斜线转义的解释(默认开启)表达方式 | 含义 |
---|---|
\a | 发出警告声 |
\b | 删除前一个字符 |
\c | 最后不加上换行符号 |
\f | 换行但光标仍旧停留在原来的位置 |
\n | 换行且光标移至行首 |
\r | 光标移至行首,但不换行 |
\t | 插入 tab |
\v | 与 \f 相同 |
\\ | 插入 \ 字符 |
\nnn | 插入 nnn (八进制)所代表的 ASCII 字符 |
shell
脚本编写用户输入提示1 | # test.sh |
1 | # test.sh |
1 | root_absolute_dir=$(cd "$(dirname "$0")";pwd) |
1 | root_absolute_dir=$(dirname $(readlink -f "$0")) |
macOS
中使用 readlink -f
命令会有如下报错:
1 | readlink: illegal option -- f |
可以安装 greadlink
代替 readlink
1 | # 这里仅提供使用 Homebrew 安装方法 |