关于Python API设计:OORedis项目记事(第一部分)
嘿,让我们换种方式
当我刚开始关注API设计的时候,我决定先找一些相关的资料来看,比如博客日志、PPT还有书,这方面的资料很少,而且最后我发现他们很多都只是单调地列举一些有用的规则,并没有仔细地展开讨论,这些规则可能是有用的,但读起来让人感觉相当乏味,所以我决定自己来写一篇(可能是几篇)关于API设计的文章。
于是我列了一个提纲,把我认为重要的设计原则记录下来,然后对着每条要点准备虚构一个声色俱全的故事,然后我发现我自己的文章变成了之前我看过的八股文格式。。。
于是我决定换种方式,拿之前写的ooredis项目作为引子,来谈谈Python API设计方面的事情,有时候我也引用一些Python方面著名的项目比如Django来说事儿,但大多数时候,这篇文章看上去更像是“ooredis开发记事”。
文章里所说的都是在写ooredis时真实遇到的问题,我想这样比起总结两条基本原则再虚构一些例子强多了,当然我在这方面的经验也不多,主要是就这个话题抛砖引肉一下,希望大家注意到API设计的重要性。
缘起
大概在七月份的时候,我译完了Redis的命令参考前几章,那时候我刚开始学习Redis不久,当时用的是redis-py库,这个库是面向过程的,只是Redis命令的简单包装,比如一个HSET命令,在Redis里是:
hset key field value
而在redis-py里则是:
from redis import Redis
client = Redis()
client.hset(key, field, value)
这样的库有几个问题:
第一,大量的命令聚在在一起,污染了客户端的命名空间。
如果你用dir(Redis())查看redis-py的对象,你会发现数十个方法聚集在了这个客户端对象里面,用眼睛检索这种对象的方法实在是太累人了,很难在命令行中使用这个库。
第二,因为redis-py只有一个对象,所有命令都是通过给方法传不同的参数来执行的。
这样的问题就是你很可能在执行命令的时候犯错。
比如你想执行一系列hset命令,来保存个人信息,你执行
client.hset('person', 'name', 'peter')
client.hset('person', 'age', 25)
client.hset('perso', 'phone', 10086)
但是后来你却发现'person'哈希表里面没有'phone'这个域,你仔细看了看,发现原来前面的命令最后一行,你错误地将'person'写成了'perso',你将'phone'保存到了'perso'哈希表里,噢。
如果有一个对象实例作为句柄,绑定'person'作为对象的参数,你是绝对不会犯这样的错误的。
第三,面向过程式的库没有利用Python语言的机制。
redis-py单纯的方法调用方式没有利用到Python语言的机制,比如迭代器、字典方法,各类魔法函数,等等,这使得redis-py用起来很不Pythonic。
最后还有缺乏一种方便的类型转换机制(redis中只保存字符串值),以及跨类型之间覆盖而不报错等(试试对一个list结构执行set命令看看)。
为了解决redis-py的以上问题,我决定在redis-py之上写一个Redis的库,称为ooredis,它将是面向对象的、Pythonic的,而且,因为这个库是一个通用库,我希望ooredis能被更多人使用,所以它必须写得比较标准,看上去比较专业——最起码,没有什么特别大的问题,最好有天成为和redis-py一样被Pythoner广为使用的库(现在ooredis还远远没有达到这个目标,唉。。。不过这不太妨碍我们的讨论,大概。。。)。
(平心而论,这样评论redis-py并不是完全正确的,作为一个底层客户端,redis-py已经提供了相当充实的功能,为在其上构造更高层次做好了准备。当然redis-py也还是有一些小问题,后面我会说到。)
what's under the box?
计算机程序很少(或者说,不可能)是全新地被编写出来的,很多时候,我们只是在一个低层抽象之上写一个更高层的抽象层,用高层抽象包裹低层抽象,并为新层次提供一簇新API,好让这个新层次作为基石,继续构建更高层的抽象:就像硬件包裹电路,操作系统包裹硬件指令,C程序在编译器之上被写出来,然后又作为其他语言(比如Python)的基石一样。
ooredis也一样,不同的是,它的目标不是构建一门新语言那样的高科技,而只是包裹一个Redis客户端而已,不过它们的道理是相同的——要在一个层次之上构建更高层次,你必须先了解(最起码是部分了解)现有的层次,这样才能写出好程序,于是我扎进redis-py和Redis命令参考里面,思考着该如何设计ooredis的类。
第一个跳出脑海的方式就是按照Redis的各类函数来分类(这里我们只考虑Redis的数据结构类命令,忽略事务、Pub/Sub等命令),用一个类包裹一簇命令,比如用BaseKey类包裹Redis的keys类函数,用String类包裹Redis的strings类函数,以此类推:
class BaseKey:
pass
class String:
pass
# 其他类...
但是这一完全直觉化的分类并不是完全正确的,比如keys类的expire、ttl、exists等命令,是Redis所有数据结构所共有的,而keys类的sort方法,则是除string结构和hash结构之外,list、set、sorted set才有的,于是我稍稍更改了一下类的设计:
class BaseKey:
# 除了sort之外,所有Redis的Key类命令
pass
class SortableKey(BaseKey):
def sort():
pass
# 没有sort方法的类
class String(BaseKey):
pass
class Hash(BaseKey):
pass
# 有sort方法的类
class List(SortableKey):
pass
class Set(SortableKey):
pass
class SortedSet(SortableKey):
pass
OK,一切顺利,似乎没有什么难的,于是我开始为各个类写相应的方法。
不过很快,我发现,有一种更好的类定义方式,比现在的类定义方式更好,于是我开始修改程序,但这一次,事情就没有那么容易了。。。
是一个(is a)和有一个(has a)
就在ooredis第一版中,我将Redis的keys类命令分为了两个类,一个BaseKey类,另一个SortableKey,然后其他数据结构如String、Hash等类继承BaseKey或SortableKey,但是仔细思考一下,就会发现这种类设计并不太正确。
拿BaseKey和SortableKey来说,你会发现其实SortableKey相比BaseKey这个类来说,我们只是想为支持sort方法的数据结构如Hash类提供sort方法而已,这个继承并不合理。
再往后面推一步,BaseKey和SortableKey,对Hash和String这些数据结构类来说,它们其实不是一个“父类”,它们只是一簇方法,我们其实不想要BaseKey和SortableKey,而只是想要一种在数据结构类里重用keys类函数的方法。
用专业点的术语来说,Redis中的string数据结构和keys类命令在ooredis中应该是“有一个(has a)”而不是“是一个(is a)”关系——我需要有一种可以组合使用各个方法的机制。
这个问题其实是相当直观的,但是很遗憾Python似乎没有提供这样的机制,也即是,简单快捷地重用方法的唯一方式,就是抽取出这个方法,比如sort方法,然后给他弄一个SortableKey类,所有要用sort方法的类就继承SortableKey方法,就是这样。
认识到这一事实让我有点难过,不过也只是一点点,“有一个”和“是一个”关系的差别听上去这似乎只是某种理论问题,毕竟多继承一两个类其实关系不大,马照跑,舞照跳——咱们可是实用主义者。说实在的,如果以前有人想跟我讨论这类问题的话,我会跟他说别闹了,拿着你的《JAVA变成死相》离我远点。
Queue、Stack和Dequeue
于是我继续前进,很快就把String类的几个方法搞定了,然后我开始写List类——用来包裹Redis的list数据结构,然后我发现我的老朋友——“是一个和有一个”问题,又拦住了我的去路。
先来分析一下Redis的list数据结构,它是一个双端队列,也即是,push和pop可以在队列的两边进行,包裹这个数据结构的一蹴而就的方式自然就是用一个List类,将所有list结构的相关命令“装”进去,这种方法简单明了,也没有什么大错。
但是我不想这么干,因为我觉得list结构按操作还可以细分为好几个类,像栈(stack,LIFO)、队列(queue,FIFO)和双端队列(dequeue),这些数据结构只有轻微差别,但是实际应用中相当有用,如果我只写一个双端队列的话,想用栈或者队列的人就得自力更生了,我不是一个自私的人
补充:Web开发 , Python ,