Photo by Samuel Sianipar on Unsplash

前言

上一篇说到写代码要对开发者、接手者友好,需要让程序扩展起来比较容易,实现「高内聚」。同样地,对用户来说,程序使用起来是否友好也是决定了他用不用你的软件的一大要素。本文我们就先说一说其中的一种使用情形:作为上游库对下游提供接口(API)。

场景

小 F 新加入了某大厂的一个基础组件部门,要负责维护一个组件的 Python SDK。这个组件本身是 Java 写的,小 F 看了一眼文档的 Quick Start,眉头逐渐紧锁:

from awesome_sdk.client import AwesomeClient
from awesome_sdk.core.auth.basic import AwesomeBasicAuth
from awesome_sdk.core.connection.tcp import AwesomeTCPConnection

# 新建一个认证对象
auth = AwesomeBasicAuth(username=os.getenv("USERNAME"), password=os.getenv("PASSWORD"))
# 新建连接
connection = AwesomeTCPConnection(host="127.0.0.1", port=5762, timeout=10, retry_times=3, auth=auth)
# 新建客户端对象
client = AwesomeClient(connection=connection, type="test", scope="read")
# 获取资源
print(client.get_resources())
# 关闭连接
connection.close()

看看这优秀的组合使用,用户用起来就像在拼乐高玩具:组成腿和脚……组成躯干和手臂……我来组成头部1!可是凑近闻一闻,小 F 仿佛闻到了爪哇咖啡的味道。没错,这个 Python 版的 SDK 最初是由组件的 Java 开发顺便写的2。具体问题在哪呢?可以总结为以下三点:

  1. 首先 import 就写了三行,而且 import 路径巨长,
  2. 其次,为了得到一个客户端对象,不得不先初始化一系列的基础对象来组装,
  3. 再次,要设置的参数居然多达 9 个(举例说明,实际可能更糟)。这些参数多亏命名规范,作用能猜得一二,否则不看文档很难知道意义。

默认参数

首先来解决参数的问题,我们真的需要让用户指定这么多参数吗?不然,我们应该尽可能提供合理的默认值。这里「合理」的意思是在大多数情况下,无需更改就能正常工作,达到真正的 Quick Start 的目的。具体到案例中,Connection 对象的大部分参数都不是必需的:host 未指定时默认 localhost,port 是该组件的本征端口号,SDK 知道,没必要要推给用户指定,timeoutretry 给个大部分时候都足够的值就好了,如果用户要自定,可以再从 API 文档或 IDE 浮出的函数签名中知道如何实现。现在只剩一个 auth 是必填的了,这非常合理:认证信息无法预知。

你可能会说,这个指责对 Java 不公平——它不支持默认参数。确实,但既然做了一个 Python SDK,就得入乡随俗,按 Pythonic 的来。

化繁为简

我们再次来审视这段基础的代码,把用户必填的信息摘出来:

  • 认证信息,用户名密码
  • 客户端的 scope 信息,包括 name

仅此而已,其他的参数用默认值,对于一个 Quick Start 来说足够了。那么屈屈这么几个参数,有必要涉及三个对象创建吗?

如无必要,勿增实体 ——奥卡姆剃刀

因为,引入一个新的类就意味着必须多一个导入。导入的东西多了,如果还是从同一个地方导入,那下游就很可能偷懒换成 from awesome import *。我眼睁睁看过这种情况的发生。那么,如何精简对象呢?

  • Client 是创建的核心,是所有 API 的归属对象,保留。
  • Connection 仅仅是用来指定连接的参数,可以换成 Client 对象上的 connect 方法。
  • Auth 是存储认证信息的容器,在 Python 中要用一个容器大可不必引入一个新的类,元组足矣。

改造以后,开头的代码变成了下面这样:

from awesome_sdk import AwesomeClient

client = AwesomeClient(type="test", scope="read", auth=(os.getenv("USERNAME"), os.getenv("PASSWORD")))

with client.connect():
    print(client.get_resources())

这里 connect() 返回了一个上下文对象,让用户无需关心关闭连接的操作。改造以后我不敢说这绝对 Pythonic 了,但市面上大部分的 Client 库大致都长这样(回想一下 mysqlclient, pika, redis 等等)。

由简入繁

自然,作为一个功能强大的 Client 库,背后远不止现在看起来的这么简单。注意一下原始的版本中为何要创建这么多类,你就会发现 Connection 的类名是叫做 AwesomeTCPConnection,也就是说可能要支持 UDP 连接;Auth 的类名叫做 AwesomeBasicAuth,意思是可能要支持除了用户名密码之外的其他认证方法。

我们其实已经在上面的接口中留出了可能:connect() 方法接受一个 cls 参数,默认值就是 AwesomeTCPConnection,看,默认参数的作用又体现了。你可以替换成 AwesomeUDPConnection。至于 auth,传入一个元组,SDK 内部会自动基于此创建AwesomeBasicAuth

if isinstance(auth, tuple):
    auth = AwesomeBasicAuth(*auth)

如果你需要其他的认证类型,自己实例化显式传入就好了:

client = AwesomeClient(..., auth=SSHAuth(...))

这里充分利用了 Python 的动态特性,一个参数可以是任何类型,换作静态语言的 SDK,不太可能这么设计。但是,这种方法也不能滥用,如果一个参数接受 7,8 种类型,那代码坏味道就出来了。我们把 BasicAuth 支持元组传入,只是因为它确实已经常用到一定的程度了。其实,该创建的实体一个也没少,只是对用户隐藏了。

大道至简,然内涵极深,既能化繁为简,也能由简入繁,若达到这种来去自如的境界,就可称得上是优秀的 API 了,用户都想用,用了又用。

实例

我不创造 API, 我设计 API ——Kenneth Reitz

说到 API 设计就绕不开 requests,我们来看看它的设计,略举几处:

  1. /api 下面的 getpost 等接口,直接暴露在 requests 的命名空间下,均衍生自 request,而后者是创建了一个 Session 对象,调用其中的 Session.request() 方法。这里就利用了隐藏实体的技巧,当用户想保持会话时,依然可以自己实例化 Session
  2. requests.get 方法中,verify 参数既可以是布尔,也可以是指向证书的路径;auth 参数既可以是(用户名,密码)元组,也可以是一个 Auth 实例,利用了参数的多类型。而大多数时候,只需要 requests.get(url) 就够了。
  3. requests.post 方法中,只给 data 参数时是 x-www-form-urlencoded 类型,只给 json 参数是是 application/json 类型,同时给 datafiles 参数时是 multipart/form-data 类型。灵活多变,用户无需指定任何一个 Header,填写那根本记不住的 Content-Type。
  4. 用户想自定义请求数据返回,则继承 requests.adapters.BaseAdapter 自己实现一个,然后通过 Session.mount() 挂载就好了,可以不经互联网,读取本地数据,也可以与 WSGI app 直接交互,随心所欲。

撇开作者个人不谈,requests 的源码还是非常值得一读的,能提升你的 API 设计能力。

就说到这。


  1. 如果你对这话有些耳熟,你可能已经暴露年龄,这是动画《战神金刚》机器人合体的台词。

  2. 我对 Java 不熟,可能有生成工具自动转换而来。案例纯属虚构,如有雷同,八成是某云(逃。