嘿,让我们换种方式
当我刚开始关注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),这些数据结构只有轻微差别,但是实际应用中相当有用,如果我只写一个双端队列的话,想用栈或者队列的人就得自力更生了,我不是一个自私的人,而且为了ooredis将来的蓬勃发展(这一景愿至今仍未实现),多写几行代码也没啥的,于是我决定将原本的List一分为三:
class Dequeue:
# 提供表头和表尾两边的push和pop
pass
class Stack:
# 只提供表尾一边的push和pop
pass
class Queue:
# 只提供表尾的push和表头的pop
pass
很明显,这些三个类里面有一些共有的方法,比如获取列表长度的llen命令,以及读取列表项的lrange命令,但也有一些命令是某个类中独有的,比如Stack类就应该只有lpush和lpop(或者rpush和rpop),Queue应该只有lpush和rpop(或者rpush和lpop),而Dequeue则四个方法都可以有。
按照老方法,我们可以用一个GenericQueueProperty之类的类,将列表的通用方法装进去,然后Stack加上lpush和lpop,给Queue加上lpush和rpop,然后Dequeue继承Stack和Queue(只为重用方法)。
最终,我们类成了一团糟:
class BaseKey:
pass
class SortableKey(BaseKey):
pass
class GenericQueueProperty(SortableKey):
# 提供队列的共有属性和方法
pass
class Stack(GenericQueueProperty):
# 只提供表尾一边的push和pop
pass
class Queue(GenericQueueProperty):
# 只提供表尾的push和表头的pop
pass
class Dequeue(Stack, Queue):
# push和pop可以在两边进行
pass
解决方法
上面Dequeue类的定义让人感觉自己像是错过了京东买100送100活动一样难过,很自然地,你会问,是否有更好的办法在Python中解决重用方法的问题?
有人推荐使用多继承来解决方法重用的问题,这样的话,Dequeue的定义将是这样:
class SortableKey:
# 提供sort方法,但不继承BaseKey
pass
class LeftSideOperation:
# 提供lpush和lpop
pass
class RightSideOperation:
# 提供rpush和rpop
pass
class Dequeue(BaseKey, SortableKey, GenericQueueProperty, LeftSideOperation, RightSideOperation):
pass
这种方法的特点其实就是用继承数量换继承高度,其实复杂性是没有变的,一棵高高的继承树和一串长长的继承列表之间,我真的说不清楚它们到底那个好一些。
而且这种方法有一个很隐晦的危险性,考虑如果你在继承列表中的类A中,定义了foo方法,但是在类B中,你又定义了一个foo方法,这样的话,它们就会互相覆盖,而在Python中这种覆盖是没有任何警告的,你继承的类越多,就可能越出现这种问题,一但这种问题出现,你就要检查所有继承类,如果你只有一个基类,那你就回溯祖先链,看看是那个环节出了问题;如果你有两个类,你的工作量就多了一倍;如果你有很多个基类。。。祝你好运!
Python标准库提供了另外一种思路,就是使用钩子方法:基类定义一些通用操作,比如push方法,push方法调用钩子_push方法,而派生类则通过覆盖_push方法,来提供不同的行为,比如这样:
class GenericQueue:
def push():
pass
def _push():
pass
def pop():
pass
def _pop():
pass
class Stack(GenericQueue):
def _push():
# lpush
pass
def _pop():
# lpop
pass
class Queue(GenericQueue):
def _push():
# lpush
pass
def _pop():
# rpop
pass
这种方法的问题是你要写很多额外的钩子方法,你必须小心处理,以免遗漏了哪一个或者不小心把_push写成了push,诸如此类。
说实在的,这种方法相当丑陋。
one more time, one more chance
以上两种方法都治标不治本,它们解决一些问题的同时也引入了一些更大的问题,究其原因,是因为Python里没有一种好的方法来重用已有方法,继承是重用方法的唯一简单快捷的手段。
必须承认我写ooredis时思维有点僵硬了,总是想着怎么用Python解决这个问题,而没有想到换一种语言来试试,比如在Ruby中,解决这个问题就相当简单:
module BaseKey
# 所有除了sort方法之外的keys类方法
end
module SortableKey
def sort
end
end
module GenericQueue
# 队列共有的属性和方法
end
module LeftSideOperation:
# lpush & lpop
end
module RightSideOperation:
# rpop & rpush
end
class Dequeue
include BaseKey
include SortableKey
include GenericQueue
include LeftSideOperation
include RightSideOperation
end
这个Dequeue没有继承任何类,因为它本身已经是一个key,它和BaseKey、SortableKey等模块的关系是有一个而不是是一个,这才是正确的语义。(这个类混入的模块有点多,通过混入 Enumerable 模块,实际写起来其实会更简单。)
我最终选择了多继承来实现ooredis,而且只用一个List类包裹所有的list数据结构命令,因为redis的命令基本上是正交的,没有相同的方法,所以多继承的风险比较低,如果你的程序多态方法相当多,我强烈建议你不要随便使用多继承,一棵高高的继承树和一大串继承列表之间,我宁愿选择前者。
用各种hack给编程语言“打补丁”是一条不归路,如果当时能想到这个方法的话,ooredis就会是Ruby Gem而不是Python库了。
当然现在的ooredis距离我的预想也不是太远,它只是不太美而已,嗯。。。不太美。。。
待续
我有一个目标,就是让那些不会用Redis的Python使用者不用学一条Redis命令,就能用我的ooredis。这可能吗?如果可以的话,怎么达到这一目标?
其次,为什么说好的API和好的程序一样,都是重用为主?创造和创新有什么区别?
下一篇文章,我们就来谈谈关于一致性的思考,看看如何达到我们的目标——写出不用学习就能轻松上手的API。