前言
上一篇说到写代码要对开发者、接手者友好,需要让程序扩展起来比较容易,实现「高内聚」。同样地,对用户来说,程序使用起来是否友好也是决定了他用不用你的软件的一大要素。本文我们就先说一说其中的一种使用情形:作为上游库对下游提供接口(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。具体问题在哪呢?可以总结为以下三点:
- 首先 import 就写了三行,而且 import 路径巨长,
- 其次,为了得到一个客户端对象,不得不先初始化一系列的基础对象来组装,
- 再次,要设置的参数居然多达 9 个(举例说明,实际可能更糟)。这些参数多亏命名规范,作用能猜得一二,否则不看文档很难知道意义。
默认参数
首先来解决参数的问题,我们真的需要让用户指定这么多参数吗?不然,我们应该尽可能提供合理的默认值。这里「合理」的意思是在大多数情况下,无需更改就能正常工作,达到真正的 Quick Start 的目的。具体到案例中,Connection
对象的大部分参数都不是必需的:host
未指定时默认 localhost,port
是该组件的本征端口号,SDK 知道,没必要要推给用户指定,timeout
和 retry
给个大部分时候都足够的值就好了,如果用户要自定,可以再从 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,我们来看看它的设计,略举几处:
/api
下面的get
,post
等接口,直接暴露在requests
的命名空间下,均衍生自request
,而后者是创建了一个Session
对象,调用其中的Session.request()
方法。这里就利用了隐藏实体的技巧,当用户想保持会话时,依然可以自己实例化Session
。requests.get
方法中,verify
参数既可以是布尔,也可以是指向证书的路径;auth
参数既可以是(用户名,密码)元组,也可以是一个Auth
实例,利用了参数的多类型。而大多数时候,只需要requests.get(url)
就够了。requests.post
方法中,只给data
参数时是x-www-form-urlencoded
类型,只给json
参数是是application/json
类型,同时给data
和files
参数时是multipart/form-data
类型。灵活多变,用户无需指定任何一个 Header,填写那根本记不住的 Content-Type。- 用户想自定义请求数据返回,则继承
requests.adapters.BaseAdapter
自己实现一个,然后通过Session.mount()
挂载就好了,可以不经互联网,读取本地数据,也可以与 WSGI app 直接交互,随心所欲。
撇开作者个人不谈,requests 的源码还是非常值得一读的,能提升你的 API 设计能力。
就说到这。