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 经过一番思考,觉得这是一个简单的爬虫程序,于是很快写出了主体部分:

# 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 的策略类 HNSourceV2SourceRedditSource。最后实现 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 看完代码,很快啊,就加好了功能:

  1. sources/ 下面新建一个 pychina.py,实现了PyChinaSource
  2. sources/__init__.py 中新增 from sources.other import PyChinaSource **
  3. main.py 中加上 from sources import PyChinaSource
  4. get_news() 中新增 elif source == 'other' 的情形

**: 这可以将 import path 缩短

功能上线了,运行无 bug,但一天之后大家发现没有指定新闻源的时候永远看不到 Python China 的新闻。读者应该很快发现了,有处改动漏掉了:if source is None 的情况下应该加上 PyChinaSource。复盘之后小 F 接锅:新增一个策略,涉及的改动点太多了,一个不熟悉代码的人很容易漏掉。

于是小 F 略加改动,创建了一个字典来保存所有策略,消灭掉了 if-else:

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 发现这样改动点还是太多了,主要原因是这个字典得自己写,很浪费精力。有没有办法自动生成这个映射呢?用注册大法!首先写一个注册方法:

# sources/base.py
source_map: Dict[str, BaseSource] = {}

def register(source_cls):
    source_map[source_cls.name] = source_cls()
    return source_cls

然后修改下各新闻源子类

# 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 数了一数,现在如果要扩展一个新闻源,改动点还剩两个:

  1. 新增的子类文件
  2. sources/__init__.py 中导入一次

Python 这么自由,一定有办法再削减的,于是小 F 根据使用 Django 的经验想到,可以扫描 sources/ 目录下面的所有文件,获取所有新闻源,至于源的名字,放到类变量里去就好了:

# sources/hn.py
class HNSource(BaseSource):
    name = "HN"

    # 省略其他方法
# 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 的副作用无法一眼看出)。所以应该酌情使用,代码并不是越酷炫越好的,强大的武器永远要用在合适的地方。