Photo by ConvertKit on Unsplash
时隔两个月没有更新博客,这次准备来个专题「友好的 Python」。虽然我脑海中想好了几个主题,但具体写什么还不知道,这个系列能写几篇也不知道。构思一篇博客真的是太难了,至少对我这种懒人来说。
前言
Python 是一门相当灵活动态的语言,这就导致实现一件事情可用的方法往往不止一个,于是就有很多人质疑 Python 之禅中的这一句话:
There should be one-- and preferably only one --obvious way to do it. —Tim Peters
大家质疑的理由没错,这句 Python 之禅也没错——如果你能找到这样一种 preferable,obvious 的方法,那它就是 Pythonic 的。Pythonic 这个形容词虽然虚无缥渺,但我觉得这个定义是比较符合的。
忘了在哪里看到的:一个资深程序员写的代码,要能让新人看懂,一个大师级程序员写的代码,能让 CS 专业的大一学生看懂。写的代码不仅要追求性能优功能强,还有一个重要的特质——友好。友好的界面能吸引更多用户,友好的代码结构能吸引更多的贡献者。所以本文是「友好的 Python」的其中一个主题:对开发者友好之扩展友好。
场景
(此处致敬 @piglei)小 F 收到一个需求,做一个新闻聚合机器人,从一些资讯站上获取新闻,发到 IM 的频道中,并且允许用户指定某个来源。小 F 经过一番思考,觉得这是一个简单的爬虫程序,于是很快写出了主体部分:
代码语言:javascript复制# main.py
class NewsGrabber:
def get_news(self, source: Optional[str] = None) -> Iterable[News]:
# TODO
def format_news(self, news: Iterable[News]) -> str:
result: List[str] = []
for item in self.get_news():
result.append(self._format_one_news(item))
return 'n'.join(result)
def send_message(self, message: str) -> None:
channel = self._read_config()
self._send_to_channel(message, channel)
def run(self, source: Optional[str] = None) -> None:
news = self.get_news()
message = self.format_news(news)
self.send_message(message)
初次尝试
小 F 也是个老 Pythonista 了,他觉得自己写的这段非常优雅,把 get_news()
留了出来,因为新闻来源有多个,他打算应用设计模式中的「策略模式」,把每种来源作为一个单独的策略类,暴露相同的接口,他还用上了抽象类,做了一个基类出来:
# sources/base.py
from abc import ABC, abstractmethod
class BaseSource(ABC):
url: str
def get_page(self) -> HTML:
return lxml.etree.HTML(requests.get(self.url).text)
def iter_news(self) -> Iterable[News]:
return self.extract_news(self.get_page())
@abstractmethod
def extract_news(self, html: HTML) -> Iterable[News]:
pass
接着,他通过子类做出了 HackerNews,V2EX,Reddit 的策略类 HNSource
,V2Source
,RedditSource
。最后实现 get_news
方法:
# main.py
import itertools
from sources import HNSource, V2Source, RedditSource
class NewsGrabber:
def get_news(self, source: Optional[str] = None) -> Iterable[News]:
if source is None:
return itertools.chain(HNSource().iter_news(), V2Source().iter_news(), RedditSource().iter_news())
if source == 'HN':
return HNSource().iter_news()
elif source == 'V2':
return V2Source().iternews()
elif source == 'Reddit':
return RedditSource().iternews()
else:
raise ValueError(f"Not supported source: {source}")
*: itertools.chain()
可以拼接多个可迭代对象,依次迭代。
功能上线,领导很满意,小伙伴们现在能在 IM 里直接看新闻了。
消灭 if-else
过了一礼拜,领导要加一个新闻源 Python China,小 F 觉得自己架子搭得很好了,于是就交给了新来的小 M 去做,小 M 看完代码,很快啊,就加好了功能:
- 在
sources/
下面新建一个pychina.py
,实现了PyChinaSource
- 在
sources/__init__.py
中新增from sources.other import PyChinaSource
** - 在
main.py
中加上from sources import PyChinaSource
- 在
get_news()
中新增elif source == 'other'
的情形
**: 这可以将 import path 缩短
功能上线了,运行无 bug,但一天之后大家发现没有指定新闻源的时候永远看不到 Python China 的新闻。读者应该很快发现了,有处改动漏掉了:if source is None
的情况下应该加上 PyChinaSource
。复盘之后小 F 接锅:新增一个策略,涉及的改动点太多了,一个不熟悉代码的人很容易漏掉。
于是小 F 略加改动,创建了一个字典来保存所有策略,消灭掉了 if-else:
代码语言:javascript复制source_map = {'HN': HNSource(), 'V2': V2Source(), 'Reddit': RedditSource(), 'PyChina': PyChinaSource()}
class NewsGrabber:
def get_news(self, source: Optional[str] = None) -> Iterable[News]:
if source is None:
return itertools.chain.from_iterable(source.iter_news() for source in source_map.values())
try:
return source_map(source).iter_news()
except KeyError:
raise ValueError(f"Not supported source: {source}")
这下改动点减少了一个(get_news()
内部不用改动,但source_map
新增一个改动点)。
注册中心
小 F 发现这样改动点还是太多了,主要原因是这个字典得自己写,很浪费精力。有没有办法自动生成这个映射呢?用注册大法!首先写一个注册方法:
代码语言:javascript复制# sources/base.py
source_map: Dict[str, BaseSource] = {}
def register(source_cls):
source_map[source_cls.name] = source_cls()
return source_cls
然后修改下各新闻源子类
代码语言:javascript复制# sources/hn.py
from sources.base import BaseSource, register
@register
class HNSource(BaseSource):
name = "HN"
# 省略其他方法
这样做的好处是,所有和一个新闻源相关的参数都集中到一处了,开发者在扩展新的新闻源的时候,关注点无需在不同文件中跳来跳去。免去了「东市买骏马,西市买鞍鞯」的苦恼,一站式的体验,让程序更「友好」了。当然,在 sources/__init__.py
中还是得导入这些文件(from sources import hn
就够了,无需导入具体子类)。
在实际开发中,只要遇到类似「通过某短名反查具体对象」的场景,就可以上注册中心。各大 Web 框架的路由无不是这个模式的应用。用注册中心永远好过 eval
或者从 globals()
里面反查对象,前者才是 Pythonic 的。
启用魔法
改完之后小 F 数了一数,现在如果要扩展一个新闻源,改动点还剩两个:
- 新增的子类文件
- 在
sources/__init__.py
中导入一次
Python 这么自由,一定有办法再削减的,于是小 F 根据使用 Django 的经验想到,可以扫描 sources/
目录下面的所有文件,获取所有新闻源,至于源的名字,放到类变量里去就好了:
# sources/hn.py
class HNSource(BaseSource):
name = "HN"
# 省略其他方法
代码语言:javascript复制# main.py
import importlib
from sources import source_map
for name in os.listdir("sources"):
if not name.endswith(".py") or name == "base.py":
# 跳过抽象基类文件
continue
importlib.import_module(f"sources.{name}") # 动态导入
导入模块的时候会隐式地更新 source_map
,由于 source_map
是可变对象,所以可以先导入,再更新它。现在如果要新增一个新闻源,只要复制粘贴出一个新文件,依葫芦画瓢改改就行了,小 F 可以放心地把这个活交给新人,因为整个程序扩展起来非常友好。
总结
本文介绍了如何使用 Python 的特性把一个功能扩展的开发逐步收拢到只有一个改动点。改动收拢,出 bug 的可能性就小。上面的案例并非脱离实际,而是我在项目实践中经常遇到的一个场景——策略与注册,pdm 的 CLI 命令就是通过这个手段组装起来的。值得注意的是,上面虽然通过启用魔法把扩展操作改进得非常友好,却损失了一些阅读代码的友好度——它把一些显式的操作变得有些隐晦(在 for 循环中 import_module
的副作用无法一眼看出)。所以应该酌情使用,代码并不是越酷炫越好的,强大的武器永远要用在合适的地方。