扩展性
我在编程时有一个困惑,那就是如何让软件具备扩展性(highly extensible)。
大多时候,我们无法预测软件未来有什么改动,等到了需要添加新功能时就会发现改动面积巨大,非常头痛。我之前的方案只停留在基础层面:多做复用(抽象成函数、常量)、不要写极度冗长的函数、避免单个函数内有大量控制流和状态变量、写自动化测试等等。虽然真正落实这些,可能已超越了国内大部分项目组,但是我还想更进一步。因为我总觉得,就算做到了这些还是欠缺一些东西。似乎我的拼图里少一些拼图块,缺乏一些更加进阶的思想和技巧。
我在提升程序的扩展性方面,经历了多个阶段:
- 在2018年初学编程时,我会基于函数来编程,看到一个问题就想着应该分解为哪些函数,不像很多初学者写“面条程序",单个文件全部都是一行行的程序,没有函数拆分。
- 2019年,我学习了 OOP 的概念,会想着把问题分解为类;
- 2020年,我接触了函数式编程,写程序时会想着使用高阶函数,尽量保持尽可能多的纯函数,那一年我也理解了自动化测试的重要性,会尽量写测试覆盖各种边缘案例;
- 在 2022年,我实现了基于 Raft 的分布式系统,完整实现了 TCP 协议,在设计程序时我会思考如何设计好状态机。
这一路上我一直在扩充自己的知识库,让自己的程序越来越容易修改,比如当我使用了函数,就避免了大量的全局状态,减少了程序阅读的上下文;当我用了 OOP,想要给函数添加参数时,就不需要从调用栈的最外层函数一路改到最里层的函数,只要添加一个类的属性即可;当我使用了函数式编程,我可以放心略过各种纯函数的副作用,只关注那些修改状态值的程序;当我写了完善的自动化测试,每当改动时没有考虑到哪些情况,测试总会报错来提醒我。但是回想起来,之前工作有些非常庞杂的程序,我仍然没有办法把它们设计好,让所有的维护者可以轻松修改它。
什么样的程序是高度可扩展的?我认为解耦、整齐的程序是高度可扩展的。
解耦在软件工程上代表着程序被分成不同的单元,每次改动只需要改动其中的几个甚至一个单元,如果每次都需要改动大量的单元,说明这些单元太耦合了。单元小到可以是函数,大到可以是服务端程序的 RESTful API 和微服务。
整齐的程序意味着程序的每个单元都是分门别类放好的,非常容易阅读和寻找,自然也就容易修改。如果每次找一个程序都要在整个程序仓库里面东看西看,说明它是违背这个原则的。
基于API 的编程
直到去年学习Web 全栈时接触到了Django RESTful Framework (以下简称为 DRF)。使用它做了几个应用,又阅读它的源码后,我发现它为我解决这个问题带来了新的灵感,那就是基于 API 的编程。
什么是基于 API 的编程?我认为 API 是一种比我之前学到的函数、类、高阶函数更加抽象的概念,它代表一个程序想要做哪些事情。当我们先按照 API 来设计程序,然后再写具体的 API 的实现,最后把这些实现的分发做成可以配置的,就符合了基于 API 编程。
重构
让我们对一个具体的程序做一次重构,这样更容易理解以上的理论。
以下是一个例子,写一个函数,这个函数控制顾客如何购买商品:
def shopping(product_type, customer_type):
match product_type:
case "book":
match customer_type:
case "student":
print("buy, comment, maybe refund")
case "teacher":
print("buy, comment, maybe refund")
print("do a lot of things")
case "ticket":
match customer_type:
case "VIP":
print("prebook, buy, maybe refund")
case "non-VIP":
print("prebook, buy, maybe refund")
case _:
raise Exception("Invalid product type customer type")
这个程序在大家的工作中应该是很常见的,参数是购物类型和产品类型,根据购物类型分为买书和买电影票,其中买书的顾客分为学生和老师,买电影票的顾客分为会员和普通顾客。
总结来说就是根据参数的类型,进入不同的逻辑分支然后做不同的事情。这样的函数是极度容易膨胀的,也很容易产生大量的逻辑分支和状态变量。我之前的工作中遇到最极端的情况里,函数参数类型有三个,有三层 switch 语句进行嵌套。光想找到一个类型的分支就很困难。而且里面每种分支的逻辑细节都是直接写在这个主函数里面,没有做任何抽象,最后整个主函数超过五百行,阅读和改动都很困难,甚至不光是思维层面的困难,对眼睛都很不友好。读者可以想象把上面函数里的每个 print 语句替换成一百行程序,令人眼花缭乱。
当时我能想到的方法就是我自己写的每种分支都抽象成一个函数,这样起码可以减少主函数的细节,也可以防止各种变量冲突。虽然能这样做,对于整个项目的卫生程度已经有所提高,但还是没有解决根本问题,让未来的改动变得尽可能容易。那么如何解决根本问题呢?
我们先看重构之后希望这个函数是什么样子,然后再看看具体怎么实现。这个我们希望的样子就是 API。
因为我用 Python 举例子,先把函数重构成类:
class BetterShopping:
mapping = {
"book": {
"student": StudentBuyBook,
"teacher": TeacherBuyBook,
},
"ticket": {
"VIP": VIPBuyTicket,
"non-VIP": NonVIPBuyTicket,
},
}
def dispatch(self, product_type, customer_type):
handler = self.mapping[product_type][customer_type]()
match product_type:
case "book":
handler.buy()
handler.comment()
handler.refund()
case "ticket":
handler.prebook()
handler.buy()
handler.refund()
可以看到结构非常清晰,我们给每种逻辑写一个遵循共同接口的类,称之为 handler
然后在动态分发的方法 dispatch
中根据商品和顾客的类型来决定调用那个类,买书的类有 buy(购买)、comment(评论)、refund(退款)三个方法,买电影票的类有 prebook(预定)、buy(购买)、refund(退款)三个方法。
这样的好处是什么?未来如果我们想要增加新的产品类型或者顾客类型,比如家长来买书,只要
- 实现一个类
ParentBuyBook
,遵循买书的 API。这个类和其他类都是高度隔离的,同时又可以复用逻辑。改动时不用怕改动其他部分的程序。 - 在 mapping 里面修改配置,完全不需要改动 dispatch。程序如下:
mapping = {
"book": {
"student": StudentBuyBook,
"teacher": TeacherBuyBook,
"parent": ParentBuyBook,
},
"ticket": {
"VIP": VIPBuyTicket,
"non-VIP": NonVIPBuyTicket,
},
}
这样就实现了软件工程中的解耦,修改程序时尽量只修改隔离好的一部分,而不是全局东改西改。
一个框架如果按照这样的方式编程,本身就可以写好一些基础的 API 的实现,用户可以直接使用,也可以参考它们做更加复杂的实现。操作系统、Django、DRF 都是这样的风格。
那么前面顾客买书、买电影的类具体是怎么实现的?以下是买书的 API 实现:
class BuyBook:
def buy(self):
print("default buy book")
def comment(self):
print("default comment book")
def refund(self):
print("default refund book")
class StudentBuyBook(BuyBook):
def buy(self):
print("student buy book")
class TeacherBuyBook(BuyBook):
def comment(self):
print("teacher comment book")
可以看到 BuyBook 实现了最通用的逻辑,同时制定了接口规范。而 StudentBuyBook 和 TeacherBuyBook 来继承 BuyBook,只需要实现一些他们逻辑独特的类方法即可。这样既能隔离,又可以高度复用通用逻辑。
买电影的类同理:
class BuyTicket:
def prebook(self):
print("default prebook ticket")
def buy(self):
print("default buy ticket")
def refund(self):
print("default refund ticket")
class VIPBuyTicket(BuyTicket):
def buy(self):
print("VIP buy ticket")
class NonVIPBuyTicket(BuyTicket):
def refund(self):
print("non-VIP refund ticket")
我们甚至可以更进一步:设计更加细粒度的 API。比如对于学生买书,可以这样设计:
class StudentBuyBookProcess:
def query(self):
print("student buy book query")
def pay(self):
print("student buy book pay")
class StudentBuyBook(BuyBook):
buyProcess = StudentBuyBookProcess
def buy(self):
self.buyProcess.query()
self.buyProcess.pay()
print("student buy book")
我们把买书分为两步:询问和支付,可以在 StudentBuyBook
里面指定购买书籍 API 的具体实现,未来也可以更换。
DRF 源码中真正震撼我的地方在于,整个项目分为多层,而每一层都是基于 API 来设计的,就像几何学中的分形一样。没有哪个部分是特别膨胀的,每一层都是解耦、可扩展的。
DRF 剖析
因为提升扩展性的思想收到了 DRF 的启蒙,所以我顺带分析 DRF 的源码,只对扩展性感兴趣的读者可以跳过这一小节。
DRF 本身是 Django 的一个 App,Django 本身就是高度可扩展的。但是 Django 产生于前后端不分离的时代,所以它使用模板语法做前端。到了2010年前后,前后端分离更加流行,后端只提供一个 API(往往是 JSON 格式的),这时 DRF 应运而生。它基于 Django 和 RESTful 格式,让用户能够用几行程序写出一整套 API。而且还包括序列化、反序列化、用户认证、限流、权限、搜索和过滤、文档生成等功能,这些功能本身都是高度可扩展的,作者写好了默认的实现,用户可以很轻松替换成自己的实现。
举例来说。DRF写一组增删改查的 API 是这样的:
class CustomerViewSet(
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet[Customer],
):
queryset = Customer.objects.all()
serializer_class = CustomerSerializer
permission_classes = [permissions.IsAuthenticated]
可以看到,通过继承和赋值属性写一个 ViewSet 类,就指定了 API 的具体实现。serializer_class 负责序列化、反序列化,queryset 负责数据库操作,permission_classes 负责权限。
它生成的一组接口是这样的:
GET /api/customer/
POST /api/customer/
GET /api/customer/{id}/
PUT /api/customer/{id}/
PATCH /api/customer/{id}/
DELETE /api/customer/{id}/
就用如此简洁的程序,就生成了一组增删改查的 后端 RESTful API。
如果我们看权限相关,permissions.IsAuthenticated 的实现是这样的:
class IsAuthenticated(BasePermission):
"""
Allows access only to authenticated users.
"""
def has_permission(self, request, view):
return bool(request.user and request.user.is_authenticated)
它是 permission API 的具体实现,permission 又是这样的:
class BasePermission(metaclass=BasePermissionMetaclass):
"""
A base class from which all permission classes should inherit.
"""
def has_permission(self, request, view):
"""
Return `True` if permission is granted, `False` otherwise.
"""
return True
def has_object_permission(self, request, view, obj):
"""
Return `True` if permission is granted, `False` otherwise.
"""
return True
整个 DRF 都是基于 API 积木一层层从下往上搭建的。比如 DRF 有一个可视化的调试工具,可以把后端程序返回的数据格式化和高亮,同时提供一个表单用来交互,有点类似 Postman。
我对于这部分的源码非常好奇,阅读前以为作者是独立于 DRF 其他部分的程序写了一个前端程序,但是当我阅读源码时,惊讶地发现它只是响应渲染 API 的一个具体实现!响应渲染提供了一个 API,如下:
class BaseRenderer:
"""
All renderers should extend this class, setting the `media_type`
and `format` attributes, and override the `.render()` method.
"""
media_type = None
format = None
charset = 'utf-8'
render_style = 'text'
def render(self, data, accepted_media_type=None, renderer_context=None):
raise NotImplementedError('Renderer class requires .render() to be implemented')
DRF 提供的实现有 JSON(JSONRenderer)、HTML(TemplateHTMLRenderer)、HTML Form(HTMLFormRenderer)还有可视化调试工具(BrowsableAPIRenderer)等等。我没想到的是,一个看起来这么庞大、这么独立的功能,原来只是 DRF 积木金字塔中的一个小积木,只要把渲染响应的 API 写一个具体实现即可。换之前的我,可能直接新建一个文件夹,在构造响应的时候用 switch 语句做一个分发,然后运行写好的函数。这样虽然比堆积细节让函数更加膨胀好,但是不够整齐,没有层次感、也没有复用。
之前听过 Django 创始人讲 Django 创作过程的播客,印象最深的是作者说当初创作 Django 需要在 Python 和 PHP 之间选择,作者不确定 Python 是否能行(毕竟还是二十年前),于是就把 Django 设计成了一个独立的 API,背后的实现可以替换成任何语言。当时听完觉得似懂非懂。直到读完 DRF 源码才算是理解了他思路的精妙之处。要想真正提升编程能力光多想多写是不够的,也要多看高水平的项目。在阅读 DRF 源码的过程中,我解开了困扰许久的疑惑。
一些设计上的细节
知道了基于 API 的编程,具体操作时有哪些注意事项?
我觉得这个概念只是给读者提供一个大的方向,具体操作还要在实践中慢慢积累各种经验。在阅读 DRF 的过程中,我发现了两点:
首先是如果用 OOP 来设计 API,那么这些方法往往是有调用顺序的,对于返回的值也有要求。用 DRF 做序列化和反序列化的 Serializer 举例:
class BaseSerializer(Generic[_IN], Field[Any, Any, Any, _IN]):
partial: bool
many: bool
instance: _IN | None
initial_data: Any
_context: dict[str, Any]
def __new__(cls: type[Self], *args: Any, **kwargs: Any) -> Self: ...
def __class_getitem__(cls, *args, **kwargs): ...
@classmethod
def many_init(cls, *args: Any, **kwargs: Any) -> BaseSerializer: ...
def is_valid(self, raise_exception: bool = ...) -> bool: ...
@property
def data(self) -> Any: ...
@property
def errors(self) -> Iterable[Any]: ...
@property
def validated_data(self) -> Any: ...
def update(self, instance: _IN, validated_data: Any) -> _IN: ...
def create(self, validated_data: Any) -> _IN: ...
def save(self, **kwargs: Any) -> _IN: ...
def to_representation(self, instance: _IN) -> Any: ... # type: ignore[override]
这里为了阅读方便,我把初始化的方法去掉了。上面的类,在调用 validated_data 获取校验后的数据时,需要先调用 is_valid。这时用户调用时可能会忽略这个顺序,导致出现难以调试的运行时错处,所以作者专门加了一个运行时校验,来判断用户是否按照规定的方式来调用方法。可以看下 validated_data 的实现:
@property
def validated_data(self):
if not hasattr(self, '_validated_data'):
msg = 'You must call `.is_valid()` before accessing `.validated_data`.'
raise AssertionError(msg)
return self._validated_data
可以看到,作者用 _validated_data 这个属性存在与否来判断 is_valid 是否调过。报错的信息也十分详细。如果考虑性能问题,可以把这些校验放到开发环境里。而且作者的报错信息也是非常详细的。
我们可以再看 ModelSerializer 里面的 save 方法的实现,它调用了 create 方法,里面有这样的逻辑:
self.instance = self.create(validated_data)
assert self.instance is not None, (
'`create()` did not return an object instance.'
)
可以看到作者希望 create 返回的值不能是 None,所以单独做了一个校验。如果没有这个校验,新手使用这个库时可能会在后续很远的程序得到一个运行时的报错,甚至是逻辑上的错误,都没有异常。这样调试的成本就大大提升了。
其次是使用 OOP 来做 API 设计时,出错的原因,往往是在一些我们没有预料到的地方,这个类的某个方法悄悄修改了内部的一个状态值。在我们作为新手接受一个多人维护的大型项目时,这个问题尤为严重。这种情况下,我们可以约束类的方法,确保只有少数我们规定的方法是修改状态值的,这样就把寻找状态机的上下文变得更小了。对于 CPP 可以大量使用 const 关键字,对于 Python 这种更为松散的语言,可以仿照 DRF 的作者,引导用户去修改函数的参数,而不是直接修改类的属性。比如:
def create(self, validated_data):
raise NotImplementedError('`create()` must be implemented.')
我们可以看到 create 方法是利用校验过的数据 validated_data 来生成数据库的一条数据,其实这个数据也可以通过 self._validated_data 来获得和修改,但是作者希望我们只在这个方法去修改它,所以专门把它放到了方法的参数里,显式的告诉我们要在这个方法里面读和修改这个数据。
本文的程序可以在这里下载