运维演进正确之道
想象你是一个创造者神,为一个生物设计一个身体。 在您的仁爱中,您希望生物随着时间的流逝而进化:首先,因为它必须适应其环境的变化,其次,因为您的智慧不断增长,并且您想为野兽设计更好的动物。 它不应该永远留在同一体内!
负责任的图书馆维护者也是如此。 我们恪守对依赖我们代码的人们的承诺:我们发布错误修正和有用的新功能。 如果对图书馆的未来有利,我们有时会删除功能。 我们将继续创新,但不会破坏使用我们图书馆的人的代码。 我们如何一次实现所有这些目标?
添加有用的功能
您的库在永恒中不应该保持不变:您应该添加使您的库对用户更好的功能。 例如,如果您有一个Reptile类,并且有翅膀飞翔会很有用,那就去吧。
class Reptile:
@
property
def teeth
(
self
) :
return
'sharp fangs'
# If wings are useful, add them!
@
property
def wings
(
self
) :
return
'majestic wings'
但是请注意,功能存在风险。 考虑一下Python标准库中的以下功能,并查看出了什么问题。
bool
(
datetime .
time
(
9
,
30
)
)
==
True
bool
(
datetime .
time
(
0
,
0
)
)
==
False
这是特殊的:将任何时间对象转换为布尔值都会产生True(午夜除外)。 (更糟糕的是,时区感知时间的规则更加陌生。)
我编写Python已有十多年了,但是直到上周才发现这条规则。 这种奇怪的行为会在用户代码中引起什么样的错误?
考虑具有创建事件功能的日历应用程序。 如果事件有结束时间,则该功能要求它也有开始时间。
def create_event
( day
,
start_time
=
None
,
end_time
=
None
) :
if end_time
and
not start_time:
raise
ValueError
(
"Can't pass end_time without start_time"
)
# The coven meets from midnight until 4am.
create_event
(
datetime .
date .
today
(
)
,
datetime .
time
(
0
,
0
)
,
datetime .
time
(
4
,
0
)
)
不幸的是,对于女巫来说,从午夜开始的事件未能通过此验证。 当然,一个细心的程序员在午夜就知道了这个怪癖,可以正确地编写此函数。
def create_event
( day
,
start_time
=
None
,
end_time
=
None
) :
if end_time
is
not
None
and start_time
is
None :
raise
ValueError
(
"Can't pass end_time without start_time"
)
但是这种微妙令人担忧。 如果库创建者想要创建一个可以吸引用户的API,那么像午夜的布尔转换这样的“功能”就可以很好地工作。
但是,负责任的创建者的目标是使您的库易于正确使用。
此功能由Tim Peters在2002年首次制作datetime模块时编写。即使像Tim这样的Pythonista创始人也犯错了。
该古怪已删除
,并且现在所有时间均为True。
# Python 3.5 and later.
bool
(
datetime .
time
(
9
,
30
)
)
==
True
bool
(
datetime .
time
(
0
,
0
)
)
==
True
那些不知道午夜怪异的程序员可以从模糊的bug中解救出来,但是让我不禁思索任何依赖怪异的旧行为并且没有注意到变化的代码。 如果根本不实施此不良功能,那就更好了。 这使我们想到了任何图书馆维护者的第一个诺言:
第一个盟约:避免不良特征
最痛苦的更改是必须删除功能时。 避免不良功能的一种方法是一般增加很少的功能! 没有正当理由,请勿公开任何方法,类,函数或属性。 从而:
第二盟约:最小化功能
功能就像孩子一样:在充满激情的瞬间构思出来,它们必须得到多年的支持。 不要仅仅因为可以就做任何愚蠢的事。 不要在蛇上加羽毛!
但是,当然,在很多情况下,用户需要从您的库中获取某些尚不提供的内容。 您如何选择合适的功能给他们? 这是另一个警示故事。
来自asyncio的警示故事
如您所知,当调用协程函数时,它将返回一个协程对象:
async
def my_coroutine
(
) :
pass
print
( my_coroutine
(
)
)
<coroutine object my_coroutine at 0x10bfcbac8>
您的代码必须“等待”该对象以运行协程。 忘记这一点很容易,因此asyncio的开发人员想要捕获这种错误的“调试模式”。 每当协程被破坏而没有等待时,调试模式都会打印一条警告,并回溯到创建它的行。
当Yury Selivanov实施调试模式时,他添加了“协程包装”功能作为其基础。 包装器是一个接收协程并返回任何内容的函数。 尤里(Yury)用它在每个协程上安装警告逻辑,但是其他人可以用它将协程变成字符串“ hi!”。
import
sys
def my_wrapper
( coro
) :
return
'hi!'
sys .
set_coroutine_wrapper
( my_wrapper
)
async
def my_coroutine
(
) :
pass
print
( my_coroutine
(
)
)
hi!
这是定制的地狱。 它改变了“异步”的含义。 一次调用set_coroutine_wrapper将全局且永久更改所有协程函数。
正如Nathaniel Smith所写
,这是一个“有问题的API”,容易被滥用,必须将其删除。 如果异步开发人员可以更好地根据其功能调整功能,则可以避免删除功能的麻烦。 负责任的创作者必须牢记以下几点:
第三盟约:保持特征狭窄
幸运的是,Yury有很好的判断力将该功能标记为临时,因此异步用户知道不依赖它。 Nathaniel可以自由地将
set_coroutine_wrapper
替换为更窄的功能,该功能仅自定义回溯深度。
import
sys
sys .
set_coroutine_origin_tracking_depth
(
2
)
async
def my_coroutine
(
) :
pass
print
( my_coroutine
(
)
)
<coroutine object my_coroutine at 0x10bfcbac8>
RuntimeWarning:'my_coroutine' was never awaited
Coroutine created at (most recent call last)
File "script.py", line 8, in <module>
print(my_coroutine())
这样好多了。 没有更多的全局设置可以更改协程的类型,因此异步用户无需进行防御性编码。 神灵应该像尤里一样具有远见。
第四盟约:标记实验性特征为“临时性”
如果您只有一种直觉,即您的生物想要角和四叉形的舌头,请介绍这些功能,但将其标记为“临时的”。
您可能会发现角是多余的,但四叉舌头毕竟很有用。 在库的下一个版本中,您可以删除前者并标记后者为官方。
删除功能
无论我们如何明智地指导生物的进化,都有一段时间最好删除官方功能。 例如,您可能已经创建了一只蜥蜴,现在选择删除它的腿。 也许您想将这个笨拙的生物转变为时尚现代的Python。
删除功能有两个主要原因。 首先,您可能会通过用户反馈或您不断增长的智慧发现某个功能不是一个好主意。 午夜的古怪行为就是这种情况。 或者,该功能最初可能已经很好地适应了图书馆的环境,但是生态却发生了变化。 也许另一个神发明了哺乳动物。 您的生物想挤入哺乳动物的小洞中,吃掉美味的哺乳动物填充物,因此它必须失去双腿。
同样,Python标准库会根据语言本身的更改删除功能。 考虑异步的锁。 自从将“ await”添加为关键字以来,就一直在等待:
lock
= asyncio.
Lock
(
)
async
def critical_section
(
) :
await lock
try :
print
(
'holding lock'
)
finally :
lock.
release
(
)
但是现在,我们可以执行“与锁异步”了。
lock
= asyncio.
Lock
(
)
async
def critical_section
(
) :
async
with lock:
print
(
'holding lock'
)
新样式要好得多! 它很短,并且在使用其他try-except块的大型函数中不太容易出错。 由于“应该有一种,最好只有一种明显的方式”
,Python 3.7中已弃用了旧语法,
并且很快将禁止
使用该语法
。
生态变化也不可避免地也会对您的代码产生影响,因此,请谨慎地删除功能。 在执行此操作之前,请考虑删除它的成本或收益。 负责任的维护者不愿让他们的用户更改大量代码或更改逻辑。 (记住,在重新添加回Python 3之前,Python 3删除了“ u”字符串前缀是多么痛苦。)但是,如果代码的更改是机械的,例如简单的搜索和替换,或者该功能很危险,则可能值得删除。
是否删除功能
骗局 | 专业版 |
---|---|
Code must change | 变化是机械的 |
Logic must change | 功能很危险 |
对于饥饿的蜥蜴,我们决定删除它的腿,这样它就可以滑进老鼠的洞里吃了。 我们该如何处理? 我们可以删除
walk
方法,从中更改代码:
class Reptile:
def walk
(
self
) :
print
(
'step step step'
)
对此:
class Reptile:
def slither
(
self
) :
print
(
'slide slide slide'
)
那不是一个好主意。 该生物习惯于行走! 或者,就库而言,您的用户所拥有的代码依赖于现有方法。 当他们升级到您的库的最新版本时,他们的代码将中断。
# User's code. Oops!
Reptile.
walk
(
)
因此,负责任的创作者承诺:
第五盟约:轻轻删除特征
轻轻删除功能涉及几个步骤。 从以腿走路的蜥蜴开始,首先添加新方法“ slither”。 接下来,弃用旧方法。
import
warnings
class Reptile:
def walk
(
self
) :
warnings .
warn
(
"walk is deprecated, use slither"
,
DeprecationWarning
, stacklevel
=
2
)
print
(
'step step step'
)
def slither
(
self
) :
print
(
'slide slide slide'
)
Python警告模块功能非常强大。 默认情况下,它会将警告打印到stderr,每个代码位置仅打印一次,但是您可以将警告静音或将其转变为异常,以及其他选项。
一旦将此警告添加到库中,PyCharm和其他IDE就会使用删除线来渲染已弃用的方法。 用户立即知道该方法已删除。
Reptile().
walk()
当他们使用升级的库运行代码时会发生什么?
$ python3 script.py
DeprecationWarning: walk is deprecated, use slither
script.py:
14 : Reptile
(
) .walk
(
)
step step step
默认情况下,他们会在stderr上看到警告,但脚本会成功并显示“ step step step”。 警告的回溯显示必须修复用户代码的哪一行。 (这就是“ stacklevel”参数的作用:它显示用户需要更改的调用站点,而不是库中警告生成的行。)请注意,该错误消息具有指导意义,它描述了库用户必须执行的操作迁移到新版本。
您的用户将要测试他们的代码,并证明他们没有调用过时的库方法。 仅凭警告不会使单元测试失败,但是例外会导致失败。 Python有一个命令行选项,可以将弃用警告转换为异常。
> python3 -Werror::DeprecationWarning script.py
Traceback (most recent call last):
File "script.py", line 14, in <module>
Reptile().walk()
File "script.py", line 8, in walk
DeprecationWarning, stacklevel=2)
DeprecationWarning: walk is deprecated, use slither
现在,由于脚本以错误终止,因此不打印“ step step step”。
因此,一旦发布了警告过时的“ walk”方法的库版本,就可以在下一个版本中安全地删除它。 对?
考虑您的图书馆用户在项目需求中可能会有什么。
# User's requirements.txt has a dependency on the reptile package.
reptile
下次他们部署代码时,他们将安装您库的最新版本。 如果他们还没有处理所有弃用问题,那么他们的代码将被破坏,因为它仍然依赖于“行走”。 您需要比这更温柔。 您还必须向用户保证三个承诺:维护变更日志,选择版本方案以及编写升级指南。
第六盟约:维护变更日志
您的库必须有一个变更日志; 其主要目的是宣布何时弃用或删除用户依赖的功能。
Changes in Version 1.1
|
负责任的创建者使用版本号来表示库的更改方式,以便用户可以就升级做出明智的决定。 “版本方案”是一种用于传达变化速度的语言。
第七盟约:选择版本方案
有两种广泛使用的方案,
语义版本控制
和基于时间的版本控制。 我建议几乎所有库都使用语义版本控制。 它的Python风格在
PEP 440中
定义,并且
pip之类的
工具可以理解语义版本号。
如果为库选择语义版本控制,则可以使用以下版本号轻轻删除其分支:
1.0:第一个“稳定”发行版,带有walk()
1.1:添加splitter(),弃用walk()
2.0:删除walk()
您的用户应取决于您的库的版本范围,例如:
# User's requirements.txt.
reptile>=1,<2
这样一来,他们就可以在主要版本中自动升级,收到错误修正并可能会提出一些弃用警告,但不能升级到
下一个
主要版本,并且可能会遭受破坏其代码的更改。
如果您遵循基于时间的版本控制,则您的发行版可能会这样编号:
2017.06.0:2017年6月发行
2018.11.0:添加了split(),弃用walk()
2019.04.0:删除walk()
用户可以依赖您的库,例如:
# User's requirements.txt for time-based version.
reptile==2018.11.*
这太了不起了,但是您的用户如何知道您的版本控制方案以及如何测试其代码的弃用? 您必须建议他们如何升级。
第八盟约:撰写升级指南
这是负责任的库创建者如何指导用户的方式:
Upgrading to 2.0
|
您必须通过向用户显示命令行选项来教用户如何处理弃用警告。 并非所有的Python程序员都知道这一点—我当然必须每次都要查询语法。 请注意,您必须
发布
一个版本,该版本会打印每个不赞成使用的API的警告,以便用户可以在再次升级之前使用该版本进行测试。 在此示例中,版本1.1是桥发行版。 它使您的用户可以增量地重写其代码,分别修复每个弃用警告,直到他们完全迁移到最新的API。 他们可以相互独立地测试对其代码的更改和库中的更改,并找出错误的原因。
如果选择了语义版本控制,则过渡期将持续到下一个主要版本,即从1.x到2.0,或从2.x到3.0,依此类推。 删除生物腿部的一种温和方法是给它至少一个版本以调整其生活方式。 不要一次移走腿!
版本号,弃用警告,变更日志和升级指南共同作用,可以在不违反用户盟约的情况下轻轻地扩展您的库。
Twisted项目的“兼容性策略”
很好地解释了这一点:
“第一个永远免费”
任何运行时未发出警告的应用程序都可以升级为Twisted的次要版本。
换句话说,任何运行测试而不触发Twisted警告的应用程序都应该能够至少一次升级其Twisted版本,除非可能产生新警告,否则不会造成不良影响。
现在,我们的创造神已经获得了通过添加方法来添加功能并轻轻删除它们的智慧和力量。 我们还可以通过添加参数来添加功能,但这带来了新的难度。 你准备好了吗?
添加参数
想象一下,您刚刚给了蛇形生物一对翅膀。 现在,您必须允许它选择是滑行还是飞行。 当前,其“移动”功能采用一个参数。
# Your library code.
def move
( direction
) :
print
( f
'slither {direction}'
)
# A user's application.
move
(
'north'
)
您想添加一个“模式”参数,但是如果用户升级,这将破坏用户的代码,因为他们仅传递一个参数。
# Your library code.
def move
( direction
, mode
) :
assert mode
in
(
'slither'
,
'fly'
)
print
( f
'{mode} {direction}'
)
# A user's application. Error!
move
(
'north'
)
一个真正明智的创建者承诺不会以这种方式破坏用户的代码。
第九盟约:兼容添加参数
为了保持此盟约,请为每个新参数添加保留原始行为的默认值。
# Your library code.
def move
( direction
, mode
=
'slither'
) :
assert mode
in
(
'slither'
,
'fly'
)
print
( f
'{mode} {direction}'
)
# A user's application.
move
(
'north'
)
随着时间的流逝,参数是功能演化的自然历史。 它们以最旧的顺序列出,每个都有默认值。 库用户可以传递关键字参数以选择特定的新行为,并接受所有其他行为的默认值。
# Your library code.
def move
( direction
,
mode
=
'slither'
,
turbo
=
False
,
extra_sinuous
=
False
,
hail_lyft
=
False
) :
# ...
# A user's application.
move
(
'north'
, extra_sinuous
=
True
)
但是,用户可能会编写如下代码:
# A user's application, poorly-written.
move
(
'north'
,
'slither'
,
False
,
True
)
如果在库的下一个主要版本中,您放弃了其中一个参数(例如“ turbo”),该怎么办?
# Your library code, next major version. "turbo" is deleted.
def move
( direction
,
mode
=
'slither'
,
extra_sinuous
=
False
,
hail_lyft
=
False
) :
# ...
# A user's application, poorly-written.
move
(
'north'
,
'slither'
,
False
,
True
)
用户的代码仍然可以编译,这是一件坏事。 该代码停止了异常的移动,并开始向Lyft致敬,这并不是本意。 我相信您可以预测接下来要讲的内容:删除参数需要几个步骤。 首先,当然,不要使用“ turbo”参数。 我喜欢这种技术,它可以检测是否有任何用户的代码依赖此参数。
# Your library code.
_turbo_default
=
object
(
)
def move
( direction
,
mode
=
'slither'
,
turbo
= _turbo_default
,
extra_sinuous
=
False
,
hail_lyft
=
False
) :
if turbo
is
not _turbo_default:
warnings .
warn
(
"'turbo' is deprecated"
,
DeprecationWarning
,
stacklevel
=
2
)
else :
# The old default.
turbo
=
False
但是您的用户可能不会注意到该警告。 警告不是很大:警告可以在日志文件中被抑制或丢失。 用户可能会无意间升级到库的下一个主要版本,即删除“ turbo”的版本。 他们的代码将毫无错误地运行,并且默默地做错事! 就像Python的禅宗所说,“错误绝不能默默传递。” 确实,爬行动物的听觉很差,因此当他们犯错时,您必须大声地纠正它们。
保护用户的最佳方法是使用Python 3的star语法,该语法要求调用者传递关键字参数。
# Your library code.
# All arguments after "*" must be passed by keyword.
def move
( direction
,
*
,
mode
=
'slither'
,
turbo
=
False
,
extra_sinuous
=
False
,
hail_lyft
=
False
) :
# ...
# A user's application, poorly-written.
# Error! Can't use positional args, keyword args required.
move
(
'north'
,
'slither'
,
False
,
True
)
有了星号,这是唯一允许的语法:
# A user's application.
move
(
'north'
, extra_sinuous
=
True
)
现在,当您删除“ turbo”时,可以确定任何依赖它的用户代码都会大声失败。 如果您的库也支持Python 2,那没有什么可耻的。 您可以这样模拟星形语法(
归功于Brett Slatkin
):
# Your library code, Python 2 compatible.
def move
( direction
, **kwargs
) :
mode
= kwargs.
pop
(
'mode'
,
'slither'
)
turbo
= kwargs.
pop
(
'turbo'
,
False
)
sinuous
= kwargs.
pop
(
'extra_sinuous'
,
False
)
lyft
= kwargs.
pop
(
'hail_lyft'
,
False
)
if kwargs:
raise
TypeError
(
'Unexpected kwargs: %r'
% kwargs
)
# ...
要求关键字参数是一个明智的选择,但它需要有远见。 如果允许在位置上传递参数,则无法在以后的版本中将其转换为仅关键字形式。 因此,现在添加星号。 您可以在asyncio API中观察到它在构造函数,方法和函数中普遍使用星号。 到目前为止,即使“锁定”仅采用一个可选参数,asyncio开发人员也立即添加了星形。 这是天意。
# In asyncio.
class Lock:
def
__init__
(
self
, *
, loop
=
None
) :
# ...
现在,我们已经获得了在更改方法和参数的同时保持与用户的约定的智慧。 现在该尝试最具挑战性的演变了:改变行为而不改变方法或参数。
改变行为
假设您的生物是响尾蛇,并且您想教它一种新的行为。
wind绕! 该生物的身体将看起来相同,但是其行为将会改变。 我们如何为它的发展这一步做好准备?
负责任的创建者可以在以下情况下从Python标准库中学习,即在没有新功能或新参数的情况下更改了行为。 曾几何时,引入了os.stat函数来获取文件统计信息,例如创建时间。 起初,时间始终是整数。
>>>
os .
stat
(
'file.txt'
) .
st_ctime
1540817862
有一天,核心开发人员决定将浮点数用于os.stat时间,以提供亚秒级的精度。 但是他们担心现有的用户代码还没有准备好进行更改。 他们在Python 2.3中创建了一个设置“ stat_float_times”,默认情况下为false。 用户可以将其设置为True以选择浮点时间戳。
>>>
# Python 2.3.
>>>
os .
stat_float_times
(
True
)
>>>
os .
stat
(
'file.txt'
) .
st_ctime
1540817862.598021
从Python 2.5开始,浮动时间成为默认设置,因此为2.5及更高版本编写的任何新代码都可以忽略该设置并期望浮动。 当然,您可以将其设置为False以保留旧的行为,也可以将其设置为True以确保所有Python版本中的新行为,并为stat_float_times删除的那一天准备代码。
岁月流逝。 在Python 3.1中,该设置被弃用,以便为人们为遥远的未来做准备,最后,经过数十年的探索,
该设置被删除
。 浮动时间现在是唯一的选择。 这是一条漫长的道路,但是负责任的神灵是耐心的,因为我们知道这个渐进的过程很有可能使用户免于意外的行为更改。
第十盟约:逐渐改变行为
步骤如下:
- 添加一个标志以选择加入新行为,默认为False,如果为False,则发出警告
- 将默认值更改为True,完全弃用标志
- 删除标志
如果遵循语义版本控制,则版本可能如下所示:
图书馆版本 | 库API | 用户密码 |
---|---|---|
1.0 | 无标志 | 期待老行为 |
1.1 |
添加标志,默认为False, 警告是否错误 |
将标志设置为True, 处理新行为 |
2.0 |
将默认值更改为True, 完全弃用标志 |
处理新行为 |
3.0 | 删除标志 | 处理新行为 |
您需要
两个
主要版本才能完成操作。 如果您在没有中间版本的情况下直接从“添加标志,默认为False,警告它为False”变为“删除标志”,则您的用户代码将无法升级。 为1.1正确编写的用户代码将标记设置为True并处理了新的行为,必须能够升级到下一个版本,并且除新警告外不会有不良影响,但是如果该标记在下一个版本中被删除,则该代码将打破。 一个负责任的神灵从未违反扭曲政策:“第一个永远免费”。
负责任的创作者
我们的十个契约大致可分为三类:
谨慎发展
- 避免不良功能
- 最小化功能
- 保持特征狭窄
- 将实验功能标记为“临时”
- 轻轻删除功能
严格记录历史
- 维护变更日志
- 选择版本方案
- 撰写升级指南
大声变化
- 兼容添加参数
- 逐渐改变行为
如果您将这些契约与自己的生物保持在一起,那么您将成为负责任的创造者神。 您的生物的身体会随着时间的推移而进化,可以永远改善并适应其环境的变化,但不会突然改变,生物就不会为此做好准备。 如果您维护一个图书馆,请遵守对用户的承诺,您就可以在不破坏依赖您的人的代码的情况下创新您的图书馆。
本文最初出现在
A. Jesse Jiryu Davis的博客上,
并经许可被重新发布。
插图学分:
-
《世界的进步》,德尔菲学会,1913年
-
关于蛇的自然历史的论文,查尔斯·欧文,1742年
-
关于哥斯达黎加的蝙蝠毛虫和视网膜:关于尼加拉瓜和秘鲁的爬行动物学和鱼类学的笔记,爱德华·德库尔·科普,1875年
-
理查德·吕德克(Richard Lydekker)等人的《自然历史》。
1897年
-
梅斯监狱(Silvio Pellico),1843年
-
Tierfotoagentur / m.blue-shadow
-
洛杉矶公共图书馆,1930年
翻译自:
https://opensource.com/article/19/5/api-evolution-right-way
运维演进正确之道