API 與類型系統(tǒng)
由于眾所周知的原因,至今仍有大量生產(chǎn)環(huán)境的代碼跑在 Python 2.7 之上祷蝌,在 Python 2 的世界里,并沒有一個官方的類型系統(tǒng)實(shí)現(xiàn)。那么生產(chǎn)環(huán)境的類型系統(tǒng)是如何實(shí)現(xiàn)的呢春宣,為什么一定要在在線服務(wù)上實(shí)現(xiàn)類型系統(tǒng)?下文將針對這兩個問題進(jìn)行深入討論嫉你。
什么是 API 的類型系統(tǒng)
人們常說一門編程語言的類型系統(tǒng)月帝,通常指一門編程語言在表達(dá)式的類型意義上所具有的表達(dá)能力。而對于 API 來說幽污,對于其輸入(參數(shù))和輸出(響應(yīng))都能夠有完善的類型表達(dá)嚷辅,那么就可以認(rèn)為它具有了基本的類型系統(tǒng)。
一個包含了方法(Method/HTTP verb)和路徑(Path)的 API距误,常常稱之為一個訪問點(diǎn)(endpoint)或 API簸搞,每一個 API 具有一個描述性質(zhì)的聲明,稱之為 Schema准潭,Schema 可以有多種定義方式趁俊,但至少會包含參數(shù)(請求字段及其類型定義)和響應(yīng)(狀態(tài)碼,響應(yīng)字段及類型定義)刑然。比較典型的是?OpenAPI?規(guī)范的定義寺擂,該規(guī)范將在下文詳細(xì)介紹。
那么在線服務(wù)上實(shí)現(xiàn)類型系統(tǒng)有何意義?如果一個 API Framework 或者 RPC Remote Call 沒有類型系統(tǒng)沽讹,會出現(xiàn)什么樣的問題呢般卑?
為什么要在在線服務(wù)上實(shí)現(xiàn)類型系統(tǒng)
本文認(rèn)為在線服務(wù)上的類型系統(tǒng)至少有以下幾種直接的作用:
驗(yàn)證參數(shù)的可靠性爽雄,由于在服務(wù)開發(fā)時蝠检,不能信任用戶的輸入,應(yīng)做好最壞的假設(shè)焰檩,就如同墨菲在靜靜地看著你峦萎。
自動生成文檔和超文本鏈接忆首,一個完善的 Schema 系統(tǒng)妒潭,可以為?HATEOAS(Hypertext As The Engine Of Application State) 提供支持。
自動生成 Definition 文件(比如?thrift揣钦,protobuf?等 RPC 定義)雳灾,用于在服務(wù)端提供兼容多種協(xié)議的網(wǎng)關(guān),在客戶端為終端用戶提供本地驗(yàn)證機(jī)制冯凹。
和異常系統(tǒng)結(jié)合谎亩,可以為異常診斷和 Traceback 提供支持炒嘲,使用更有針對性的診斷方式。
可以和接口測試相結(jié)合匈庭,推斷返回值的類型(但 Python 2 的庫實(shí)現(xiàn)比較龐雜夫凸,很難實(shí)現(xiàn)這一點(diǎn))。
安全性和可解釋性
API 類型系統(tǒng)的作用阱持,最終可以總結(jié)為在 「 安全性 」和「 可解釋性」 上的提升夭拌。
如果沒有一個一致的類型系統(tǒng),往往要使用大量冗余代碼(自定義函數(shù))來進(jìn)行參數(shù)校驗(yàn)衷咽,而非通過自定義類型來驗(yàn)證鸽扁。并且耗費(fèi)大量的精力人工編寫接口文檔,在接口變更后還要人工修改和校對镶骗。
在類型系統(tǒng)中桶现,安全性和可解釋性是互相依存的關(guān)系,僅從安全性考慮鼎姊,如果代碼結(jié)構(gòu)合理骡和,使用自定義函數(shù)進(jìn)行參數(shù)校驗(yàn)也是可以接受的,但函數(shù)在可解釋性上是弱于類型系統(tǒng)的相寇,對于接口附加的元信息(比如參數(shù)類型慰于,參數(shù)是否可選,參數(shù)描述)難以自然地表述裆赵。
類型系統(tǒng)在提升了安全性的同時,還兼顧了系統(tǒng)的可解釋性跺嗽,這是在服務(wù)治理上非常需要的一點(diǎn)战授。
類型系統(tǒng)實(shí)踐
下面以 Python 2.7 為例,詳細(xì)介紹下如何在一個在線服務(wù)上實(shí)現(xiàn)類型系統(tǒng)桨嫁,以及類型系統(tǒng)可以幫助研發(fā)人員做哪些有意義的事情植兰。
marshmallow
Python 2 中沒有一個官方的類型系統(tǒng)實(shí)現(xiàn),所以在 API 參數(shù)的驗(yàn)證中璃吧,往往是通過外掛第三方 Schema 實(shí)現(xiàn)的楣导。
marshmallow 是本文選用的一個對類型系統(tǒng)進(jìn)行建模的 Python 庫,它有著極高的流行程度畜挨,提供了基本的類型定義筒繁、參數(shù)驗(yàn)證功能和序列化 / 反序列化機(jī)制。
現(xiàn)在假設(shè)研發(fā)團(tuán)隊(duì)要開發(fā)一個用戶相關(guān)的接口巴元,首先要對用戶這個服務(wù)資源進(jìn)行抽象定義毡咏,一個基本的 Schema 定義如下:
清單 1. 一個用戶接口參數(shù)模式定義
1
2
3
4
5
6
7
8
9
10
11
12
13
# -*- coding: utf-8 -*-
import re
from marshmallow import Schema, fields, validate
from myapp import fields as myfields
class UserSchema(Schema):
????user_id = myfields.UserId(required=True, help=u'用戶的唯一 ID')
????nickname = fields.Str(required=True,
??????????????????????????validate=validate.Length(min=2, max=20),
??????????????????????????help=u'用戶的昵稱')
????email = fields.Email(required=True, u'用戶的郵箱,不可重復(fù)')
marshmallow 自帶了許多內(nèi)建類型逮刨,比如 Email呕缭,URL,UUID 等,研發(fā)人員也可以根據(jù)業(yè)務(wù)來定制自定義類型恢总,比如上文的 UserId 可以像這樣定義:
清單 2. 自定義類型示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# -*- coding: utf-8 -*-
import re
class UserId(fields.Field):
????""" 長度為 10 - 17 的迎罗,由字母、數(shù)字片仿、下劃線組成的 ID """
????pattern = re.compile(r'^[a-zA-Z0-9\_]{10-17}$')
????# 必選的
????default_error_messages = {
????????'invalid': u'不是一個有效的用戶 ID',
????????'format': u'{value} 無法被格式化為 ID 字符串',
????}
????def _serialize(self, value, attr, obj):
????????return value
????def _deserialize(self, value, attr, data):
????????# 可以使用任何驗(yàn)證方式纹安,而不僅僅是正則表達(dá)式
????????if not self.pattern.match(value):
????????????self.fail('invalid', value=value)
????????return value
服務(wù)開發(fā)人員也可以自己寫裝飾器或使用開源的庫,比如?webargs?來根據(jù)這個 Schema 做參數(shù)驗(yàn)證(以 Flask 為例):
清單 3. Web 框架集成示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# -*- coding: utf-8 -*-
from flask import Flask, jsonify
from webargs.flaskparser import use_args
from myapp.schema import UserSchema
app = Flask(__name__)
@app.route('/', methods=('GET',))
@use_args(UserSchema)
def echo_user(args):
????return jsonify(**args)
if __name__ == '__main__':
????app.run()
在生產(chǎn)環(huán)境的服務(wù)中,通常會選擇重載 API 注冊用的裝飾器(比如 @app.route 和 @use_args)來收集 API 的定義存儲到一個全局的對象里(可能是遠(yuǎn)程對象)滋戳,來實(shí)現(xiàn)框架級的 API 反射機(jī)制钻蔑,以允許服務(wù)實(shí)例在運(yùn)行時拿到所有已注冊的 API 的聲明,以給第三方工具 / RPC 客戶端提供最新的 Schema奸鸯。
在上面的代碼定義里咪笑,大家可以發(fā)現(xiàn) API 類型系統(tǒng)中幾個重要的功能都已經(jīng)存在了:
Schema 允許以接口為粒度定義類型聲明
fields 允許自定義類型(包括類型的校驗(yàn)規(guī)則,描述和錯誤信息)
validate 允許自定義校驗(yàn)規(guī)則
webargs 幫助類型系統(tǒng)與框架進(jìn)行集成
但僅僅有這些就夠了嗎娄涩?
validator 和枚舉
在繁忙的業(yè)務(wù)系統(tǒng)開發(fā)過程中窗怒,通常需要一定程度的抽象來增強(qiáng)代碼的可重用性,比如正則表達(dá)式和枚舉等蓄拣。
枚舉是一種特殊的類型扬虚,在線服務(wù)對它的可描述性有著更多的訴求。在閱讀一個 API 的定義時球恤,人們看到枚舉字段辜昵,不僅僅想看到這個字段期望什么樣的枚舉值,更想看到每一個枚舉值所代表的涵義咽斧,這就要求類型系統(tǒng)擴(kuò)展(或許是約束)枚舉值的定義堪置。
Python 內(nèi)置的枚舉類型有它的優(yōu)勢,但枚舉值使用了包裝類型张惹,取值時需要通過 .value 函數(shù)來獲取舀锨,而本文所描述的服務(wù)已經(jīng)在線上運(yùn)行許久了,改造工程浩大宛逗,于是采用了類似于 Flask Config Object 的定義風(fēng)格坎匿。
清單 4. 一種可選的枚舉聲明定義
1
2
3
4
5
6
7
8
class UserStateEnum(object):
????OK = 0
????PENDING = 1
????__desc__ = {
????????OK: u'有效用戶',
????????PENDING: u'封禁用戶'
????}
通過定義一個類,約定類屬性名大寫為枚舉屬性雷激,描述信息放在特殊的字段里替蔬,以此來表示枚舉類型。
這是一個關(guān)鍵的思維模式:在線服務(wù)在擴(kuò)展時必須要考慮?API?的可解釋性屎暇。
異常和 RFC 4918
在線服務(wù)對于異常系統(tǒng)的訴求是將異常按照危重等級進(jìn)行分離进栽,保證高危異常的可追溯性,以及低危異常的可解釋性恭垦。
在理想的情況下快毛,可以把異常簡單分為三類:
系統(tǒng)異常格嗅,由于系統(tǒng)故障或程序 Bug 導(dǎo)致的,應(yīng)及時發(fā)送到 Issue Tracking 的系統(tǒng)中并發(fā)送警報唠帝。
業(yè)務(wù)異常屯掖,由于用戶的輸入不符合業(yè)務(wù)邏輯導(dǎo)致的異常,比如用戶不存在襟衰√可以從日志中審計(jì),可能會需要進(jìn)行 Issue Tracking瀑晒,無需報警绍坝。
參數(shù)錯誤,用戶的輸入不符合文檔約定(契約)苔悦,比如期望參數(shù)是一個 URL轩褐,但傳來一個普通字符串。同樣可以從日志中審計(jì)玖详,但無需進(jìn)行 Issue Tracking把介,無需報警。
在責(zé)權(quán)劃分上蟋座,類型系統(tǒng)應(yīng)該只包含了第三類異常拗踢,不涉及業(yè)務(wù)邏輯和系統(tǒng)異常的處理。
由于本文所描述的 Web 層遵循 REST 語義來進(jìn)行服務(wù)開發(fā)向臀,最早的 HTTP Status 使用了 500巢墅,隨著類型系統(tǒng)的完善,響應(yīng)狀態(tài)碼也逐漸細(xì)分券膀,上面三類異常分別對應(yīng) 500君纫、400、422 三種 Status Code三娩。
關(guān)于 422 狀態(tài)碼的選取庵芭,可以參考?RFC 4918?和參考文獻(xiàn)中一些有益的討論妹懒。
OpenAPI 與可解釋性
對于在線服務(wù)的描述和定義雀监,本文比較傾向于參考 OpenAPI 規(guī)范,原因是它對機(jī)器更加友好眨唬,有著嚴(yán)謹(jǐn)?shù)?Spec 定義会前,有利于生成和分析,同時背后有谷歌匾竿、微軟等商業(yè)公司和強(qiáng)大的社區(qū)支持瓦宜。
相對于API BluePrint、RAML?等規(guī)范所強(qiáng)調(diào)的人類可讀性(Human Readable)岭妖,Swagger?更加注重定義的規(guī)范化和通用性临庇,鼓勵社區(qū)共同推進(jìn)規(guī)范的演進(jìn)反璃,在本文寫作時,OpenAPI Specification(OAS) 3.0?已經(jīng)發(fā)布假夺,一個欣欣向榮的社區(qū)也是影響本文選型的關(guān)鍵因素淮蜈。
類型系統(tǒng)在這里的作用是,對在線服務(wù)的接口定義進(jìn)行描述已卷,并生成一個符合 OpenAPI 規(guī)范定義的 JSON 文檔梧田,以支持文檔生成工具(比如?Swagger)、前端 Mock 工具(比如國內(nèi)的?Easy-Mock)侧蘸、接口測試工具(比如下文提到的基于?py.test?的實(shí)現(xiàn))和前端驗(yàn)證庫的需要裁眯。
在 OpenAPI 規(guī)范中,與類型系統(tǒng)相關(guān)的部分主要集中在 paths讳癌、schema穿稳、data types 三個章節(jié),本文主要實(shí)現(xiàn) data types 章節(jié)中所描述的類型與?marshmallow?類型之間的映射析桥,這里舉幾個特殊的例子司草。
表 1 OAS Data Type 與 Marshmallow Type 的映射
OAS TypeOAS FormatMarshmallow描述
stringemailEmail電子郵件
stringuuidUUIDUUID
integerenumEnum(Int)上文中定義的枚舉類型
stringList(Str)字符串列表
在?OpenAPI?的定義里,每一個類型(type)都有一個可選的格式(format)可以定義泡仗,通常是根據(jù)業(yè)務(wù)所需來定制埋虹,這里取 fields 類的類名(小寫)作為 format 值。
這里有一個特例娩怎,對于容器類型搔课,比如 Enum 和 List,它們的類型取決于它所包裝的類型截亦,對于在線服務(wù)爬泥,常常需要類型系統(tǒng)具有確定性,是不允許 Union 類型存在的崩瓤,這樣設(shè)計(jì)主要是為了減少序列化 / 反序列化的成本袍啡,同時簡化代碼的分支邏輯。
這里舉例說明容器類型的類型定義是如何翻譯成?OpenAPI?的類型定義的:
清單 5. List(Int) 翻譯為 OpenAPI/OAS 示例
1
2
3
4
5
6
{
??"type": "array",
??"items": {
????"type": "integer"
??}
}
清單 6. Enum(Int) 翻譯為 OpenAPI/OAS 示例
1
2
3
4
5
6
7
8
9
{
??"schema": {
????"type": "integer",
????"enum": [
??????400
??????404
????]
??}
}
接口測試與文檔生成
在完成了上述基礎(chǔ)的工作之后却桶,就要與測試框架進(jìn)行集成了境输。
類型系統(tǒng)與測試框架集成的意義是什么呢?可以分兩個類別來看待:
第一個類別是需要嚴(yán)格限定接口響應(yīng)字段的類型颖系,這個時候開發(fā)人員會在代碼中對接口的響應(yīng)做類型聲明嗅剖,那么在測試用例中,類型系統(tǒng)的作用自然就是對響應(yīng)字段類型的校驗(yàn)了嘁扼,本文稱之為嚴(yán)格模式信粮。
第二個類別是接口響應(yīng)無類型聲明,那么接口的響應(yīng)定義就不再具備可解釋性趁啸,而可解釋性對自動化的文檔生成是最重要的因素强缘。本文所描述的在線業(yè)務(wù)處于這樣一個階段督惰,所以在類型系統(tǒng)實(shí)現(xiàn)中主要解決的就是這個問題。
如果沒有響應(yīng)參數(shù)的類型定義旅掂,就需要推導(dǎo)響應(yīng)的類型姑丑,類型推導(dǎo)的方式有兩種,靜態(tài)的和動態(tài)的(運(yùn)行時)辞友。
靜態(tài)分析在 Python 2 中的實(shí)現(xiàn)難度比較高栅哀,因?yàn)榇罅康牡谌綆於紱]有明確的類型信息,同時許多要經(jīng)過網(wǎng)絡(luò)的上下游服務(wù)也都沒有提供嚴(yán)格的定義称龙,難以在這樣復(fù)雜的環(huán)境中通過靜態(tài)分析拿到接口響應(yīng)類型信息留拾。
由于團(tuán)隊(duì)有寫接口測試的習(xí)慣,最終選擇了在運(yùn)行接口測試的時候鲫尊,和 Python 的測試框架 py.test 集成痴柔,通過收集接口測試的返回值來做運(yùn)行時的類型推導(dǎo)。
下面盡可能簡單地描述一下一個真實(shí)的實(shí)現(xiàn)疫向,本文使用?yaml?來做用例的定義咳蔚,比如:
清單 7. 使用 Yaml 描述的測試用例示例
1
2
3
4
5
6
7
8
- uri: /echo
??method: GET
??desc: 測試 ECHO 服務(wù)
??status: 200
??params:
????ping: "pong"
??responses:
????ping: "pong"
Pytest 提供了參數(shù)化的功能可以用來生成用例,apis 是用例定義的列表:
清單 8. 描述文件與 pytest 集成的示例
1
2
3
4
5
@pytest.mark.parametrize("case", apis)
def test_api(case, case_manager, mocker):
????case_obj = case_manager.add(case)
????case_obj.run(mocker)
????print(case_obj.real_response)
用例執(zhí)行后搔驼,用例的響應(yīng)被保存下來谈火,再嘗試對每一個響應(yīng)字段的值做一個簡單的類型推導(dǎo)。
清單 9. 一種響應(yīng)值類型推導(dǎo)的實(shí)現(xiàn)示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
pattern_inferer_map = {
????date_pattern: {'type': 'string', 'format': 'date'},
????datetime_pattern: {'type': 'string', 'format': 'date-time'},
????ip_pattern: {'type': 'string', 'format': 'ip'},
????uuid_pattern: {'type': 'string', 'format': 'uuid'},
????base64_pattern: {'type': 'string', 'format': 'byte'},
????// ...
}
def infer_value(value):
????if isinstance(value, string_types):
????????for pattern, type_info in pattern_inferer_map.items():
????????????if pattern.match(value):
????????????????return type_info
????????return {'type': 'string'}
????elif isinstance(value, int):
????????return {'type': 'number', 'format': 'int64'}
????elif isinstance(value, float):
????????return {'type': 'number', 'format': 'double'}
????elif isinstance(value, bool):
????????return {'type': 'boolean'}
def inferer_response(response):
????return {k: infer_value(v) for k, v in response.items()}
暴力地對 Python 類型和 OAS 的類型做一個映射舌涨,這樣就用最簡單的辦法完成了一個接口響應(yīng)的類型推斷糯耍。
很容易看出,這樣的類型推斷會存在許多問題囊嘉,比如 int 和 float 類型的精度無法表達(dá)温技,字符串類型的 format 可能會有誤判,尤其依賴完備的測試用例等等扭粱。
但本文為什么仍然愿意推薦這種方法舵鳞,因?yàn)樗梢允褂米钚〉某杀荆畲笙薅鹊貪M足研發(fā)人員的基本訴求——拿到接口相應(yīng)的基本類型信息琢蛤,提升可解釋性蜓堕,這是類型系統(tǒng)中非常重要的一部分。
小結(jié)
就這樣虐块,本文通過重載服務(wù)框架的路由裝飾器來收集 API 的參數(shù)類型信息俩滥,通過接口測試來收集 API 的響應(yīng)類型信息嘉蕾,通過注冊自定義的枚舉類型和業(yè)務(wù)類型贺奠,再配合框架本身的屬性,就可以生成定制化的错忱、符合 OpenAPI 規(guī)范的文檔了儡率。
擁有類型系統(tǒng)的在線服務(wù)挂据,在接口校驗(yàn)、異常處理儿普、測試和文檔生成等方面都有全方位的提升崎逃,滿足了工程師們對一個服務(wù)在安全性和可解釋性上的基本訴求,這是非常值得投入的一件事眉孩。
新世界的戰(zhàn)鼓
上文介紹了過去兩年間个绍,我在 Python 2 在線服務(wù)類型系統(tǒng)中的一些思考與實(shí)踐。與此同時 Python 也在迅速發(fā)展浪汪,包括 Instgram 在內(nèi)的諸多公司巴柿,已將 Python 3 應(yīng)用于生產(chǎn)環(huán)境了。