嘿,讓我們換種方式
當我剛開始關注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。