用 GraphQL 快速搭建服務端 API
Glow 從今年 4 月開始為中國的產品「共樂孕」app 的使用者開發社群功能,雖然在之前美國的社群的類似的功能都進行過實現,但我們仍然決定要在這次中國的社群產品開發中嘗試一些新東西。其中就包括 GraphQL 。
今天的文章中會簡單介紹下 GraphQL 和我們在伺服器端使用的第三方庫-- Graphene-Python, 以及我們選擇這個技術的原因。並通過一些簡單的例子展現如何快速上手 GraphQL 。
GraphQL 的介紹
什麼是 GraphQL
簡單來說,GraphQL 是一種查詢語言,它被設計出來的初衷是用於提供 API。
與 RESTful 設計不同,GraphQL 一般僅暴露出一個介面供使用,而具體一個請求中需要什麼資料,資料怎麼樣組織完全由 API 的使用者(客戶端)來指定。 當然,哪些資料可以被查詢,資料的型別是怎麼樣的,則是由服務端給定的。 指定的方式就是傳入一段關於想要的結果(或操作)的描述,服務端保證返回符合要求的結果或報錯。
這篇文章不是重點介紹 GraphQL 本身,就不展開講了,如果想深入瞭解可以訪問 graphql.org。
對於完全沒有接觸過 GraphQL 的讀者,我們舉個例子幫助理解:
考慮現有資料實體 Starship 和 Crew ,它們的物件型別如下:
type Crew {
name: String,
specie: Species,
}
type Starship {
registry: String,
name: String,
crewNum: Int,
crew: [Crew]
}
code 1.1
我們可以通過以下的查詢語句來查詢「聯邦星艦進取號」及其引數:(下例中的語句和返回結果都是示例,不完全符合 GraphQL 的語法)
query {
starship(registry: "NCC-1701") {
registry,
name,
}
}
code 1.2
我們就可以得到查詢結果
{
"starship": {
"registry": "NCC-1701",
"name": "聯邦星艦進取號"
}
}
來看看更完整的例子:
fig 1.1
實際使用中,服務端返回的結果會根據查詢語句發生變化,比如 fig 1.1 中第一次查詢了 Starship 的 registry 和 name 屬性,第二次查詢 crewNum 和 crew 屬性。同時可以看到,crew 屬性是一個集合屬性,每一個元素又是型別 Crew ,我們還可以(也必須)在查詢語句中指定哪些 Crew 的欄位是需要返回的。
( fig 1.1 中這個看起來很好用的圖形介面叫做 GraphiQL,是一個基於瀏覽器的 GraphQL 快速互動 IDE,後面介紹 Flask 整合的章節中也會提到。)
Python 的 GraphQL 庫:Graphene
Glow 的伺服器語言是 Python 。所以我們就選用了比較出名的 Graphene-Python 。
主要看中的是 Graphene 成名較早有一定數量的使用者,以及配套的、適用於 Glow 技術棧的整合元件(比如 Graphene-SQLAlchemy 和 Flask-GraphQL )。
為什麼選擇 GraphQL
GraphQL 本身的概念和使用都比較直觀,對於開發者來說,比起怎麼使用它更終要的事情是瞭解自身需求並覺得是否需要使用 GraphQL 以及如何使用。那麼對於 Glow 的開發團隊,它吸引我們的地方在哪呢?
強型別
Glow 的服務端語言是 Python ,客戶端與服務端的通訊又很大程度依賴於 json 。這種情況下,對資料型別嚴格要求的 GraphQL 就能有助於減少型別不嚴格導致的問題。在客戶端,也可以放心大膽地根據事先給定的資料型別來使用服務端返回的結果,不必做許多額外的檢查甚至是型別轉換。
更容易支援客戶端的版本更迭
當客戶端進行升級,原有的欄位不需要了或者要增加新的欄位時,只需要新的客戶端使用新的查詢語句即可(當然服務端仍然需要能夠支援提供新的欄位)。這樣既可以避免不同客戶端取到冗餘的、不需要的欄位,又可以避免維護多個版本的 API 。
良好的「自說明性」
給 API 撰寫文件是費時費力的工作,其實文件往往要解決的問題很簡單:告訴別人我這個查詢請求了怎樣的資料,我預期會接收到怎樣的結果。雖然在 RESTful API 裡,我們可以通過路徑命名籠統知道這個請求的作用,但使用 GraphQL 就可以在通過查詢語句清晰、具體地描述這個請求的輸入和輸出。
比如在 code 1.2 中,這句語句查詢了 registry=NCC-1701 的星艦,並且返回結果裡包含該星艦的 registry 和 name 欄位,一目瞭然。
開始在伺服器端使用GraphQL
安裝
Graphene-Python 可以通過 pip 安裝,其在 pypi 上的包名為 graphene,目前大版本已經更新到了2.0,所以可以用如下命令安裝:
pip install "graphene>=2.0"
如果需要使用 SQLAlchemy 和 Flask 的整合,可以選擇繼續安裝:
pip install "graphene-sqlalchemy>=2.0"
pip install Flask-GraphQL
設計 ObjectType 並編寫 Query
下面我們就以 Starship 和 Crew 為例,演示如何比較完整地實現 GraphQL 的服務端。
參考 code 1.1 的定義,簡述一下我們的資料實體,我們有「Starship(聯邦星艦)」和「Crew (船員)」,有名字、編號、種族等欄位。Starship 可以有一組 Crew 即欄位 crew: [Crew]
,每個元素都是一個 Crew 。
那麼在安裝完所有依賴並在 .py 檔案中 import 必要的庫後,我們定義如下物件型別:
class Species(graphene.Enum):
HUMAN = 1
VULCAN = 2
class Crew(graphene.ObjectType):
specie = graphene.Field(Species)
name = graphene.String()
class Starship(graphene.ObjectType):
registry = graphene.String()
name = graphene.String()
crew_num = graphene.Int()
captain = graphene.Field(Crew)
crew = graphene.List(Crew)
code 2.1
定義非常直觀,即使沒有接觸過 GraphQL 和 Graphene-Python 的讀者想必也能明白這幾行程式碼定義了些什麼。graphene 庫提供了各種基本資料型別的定義(稱為 Scalars )供我們使用。列舉型的欄位可以通過繼承 graphene.Enum
來實現,列舉型的處理稍微有點特殊,請通過這裡瞭解更多諸如列舉變數的比較、展示的細節。
稍微要注意的是,指定欄位型別時,必須用這些資料型別定義的例項,比如 grephene.Int()
。而用 graphene.Field
或 graphene.List
來指定型別或者,則需要傳入型別的類本身,比如graphene.Field(Species)
。
另外可以看到 Starship 的 captain 欄位是另個一 ObjectType :Crew ,定義時也必須用 graphene.Field
將其封裝為一個 Field
而不能直接使用 ObjectType 。
完成資料實體的定義以後,需要定義 Schema 。 GraphQL 的 Schema 是所有操作(即 Query 和 Mutation )的根型別, GraphQL 伺服器會根據 Schema 來決定如何提取資料並驗證資料。在我們的例子中,現在僅提供 Query 以支援一個查詢操作:
class Query(graphene.ObjectType):
starship = graphene.Field(Starship, registry=graphene.String(required=True))
def resolve_starship(self, info, registry):
return None
schema = graphene.Schema(query=Query)
code 2.2
這樣我們的 Schema 框架就搭好了,雖然現在什麼都查詢不到,但已經可以通過客戶端了解到資料實體的結構和 query 的規範了,如下圖:
fig 2.1
結合 code 2.2 和 fig 2.1 ,客戶端可以知道:
- 哪些欄位是服務端會提供的:
registry
、name
、crewNum
、crew
以及他們的資料型別 - 自己應該如何查詢 Starship :通過字串型別的
registry
來指定哪艘星艦
讀者們會發現,在 code 2.1 中我們定義的欄位名都是下劃線風格( snake_case )的,如 crew_num
(當然這也是 Python 的變數命名規範),但客戶端查詢到的欄位名就變成了像 crewNum
這樣的駝峰風格。這是 Graphene-Python 預設的行為,我們可以用 snake_field = graphene.String(name='snake_field')
的方式來強制指定欄位名。不過考慮到客戶端多半是基於 Javascript 的,通常不會修改該預設行為。
後面的工作、也是關鍵的部分,就是如何實現 resolve_starship
這個方法了。簡單來說,只要接入現有的查詢邏輯(比如資料庫查詢,RPC 呼叫等)即可,這裡不展開了。下面要講到 SQLAlchemy 的整合,會提到怎樣通過整合來減少實現 resolve
的工作量。
SQLAlchemy 整合
在快速開發過程當中大家可能遇到這樣的問題,就是一套資料需要反覆定義多次,從資料庫的 SQL ,到 DAO 層,再到 API 層甚至客戶端。這些工作讓人感覺非常重複,因為大部分時候從上到下欄位名、型別都是一樣的。那下面就看下如何通過 GraphQL + SQLAlchemy 來減少重複勞動。
根據之前的描述,我們現定義 SQLAlchemy 的表及其對應對映類如下:
# Table:
starship = Table(
'starship',
metadata,
Column('registry', String(64), primary_key=True),
Column('name', String(64)),
Column('captain_name', String(64))
)
crew = Table(
'crew',
metadata,
Column('name', String(64), primary_key=True),
Column('specie', Integer),
Column('starship_registry', String(64), index=True)
)
# Mapped class:
class LnCrew(object):
pass
LnCrew.__mapper__ = mapper(LnCrew, crew)
class LnStarship(object):
pass
LnStarship.__mapper__ = mapper(LnStarship, starship, properties={
'crew': relationship(
LnCrew,
lazy='select',
primaryjoin=starship.c.registry == foreign(crew.c.starship_registry)
)
})
code 2.3
實際上定義 SQLAlchemy 的表和對映類的方式有很多種,這裡只是其中一種方法。注意到兩個對映類 LnCrew
和 LnStarship
內部其實什麼都沒做,當它們和資料表建立對映關係後查詢出的例項中會自動填充上資料庫表中定義的各欄位。而 LnStarship 的表本身沒有 crew 屬性,但在建立對映時我們將它指定為一種關係並通過 primaryjoin=starship.c.registry == foreign(crew.c.starship_registry)
和 LnCrew 關聯起來。
到了這一步,熟悉 SQLAlchemy 的讀者肯定能想到 code 2.2 中的 resolve_starship
方法可以很方便地這麼來實現:
... ...
def resolve_starship(self, info, registry):
return Session.query(LnStarship).filter_by(registry=registry).first()
code 2.4
其中 Session
是 SQLAlchemy 的 Session 物件,整個資料庫查詢的語法也都是 SQLAlchemy 的語法,這裡不加贅述。但這麼實現完了似乎心有不甘,好像還是有一些欄位在資料庫表裡定義了,在 GraphQL 的物件型別 code 2.1 裡被重複定義了?
所以,下一步就是藉助 Graphene-SQLAlchemy 的能力,進一步減少重複工作。現在我們把 code 2.1 和中的對映類和物件型別進行改造:
class Crew(SQLAlchemyObjectType):
specie = graphene.Field(Species)
class Meta:
model = LnCrew
class Starship(SQLAlchemyObjectType):
class Meta:
model = LnStarship
code 2.5
改動主要包括:
- 令 GraphQL 的物件型別繼承自 SQLAlchemyObjectType ,並在類中定義 Meta 類指定相關的 SQLAlchemy 對映類作為模型;
- 移除所有重複的欄位定義 (✌️);
- 保留資料庫定義與 GraphQL 物件型別定義不完全相同的欄位,如 Crew 的 specie 在資料庫中用整型表示,但這裡仍將其定義為列舉型 Sepcies 。
讓我們看一下查詢語句和執行結果:
// Query:
query QueryStarship {
starship (registry:"NCC-1701") {
name,
captainName,
crew {
name,
specie,
}
}
}
// Result:
{
"data": {
"starship": {
"name": "USS Enterprise",
"captainName": "Kirk",
"crew": [
{
"name": "Kirk",
"specie": "HUMAN"
},
{
"name": "McCoy",
"specie": "HUMAN"
},
{
"name": "Scotty",
"specie": "HUMAN"
},
... ...
]
}
}
}
code 2.6
由於 Graphene-SQLAlchemy 的存在,繼承自 SQLALchemyObjectType 的物件型別的屬性都可以簡單地通過資料庫型別來推導,在不需要另外定義的情況下,Starship 的 name, captainName 甚至是複合列表屬性 crew 也能正常查詢。
可以看到 crew 裡每個元素的 specie 屬性最後是以字串常量的形式返回的,這歸功於我們在 code 2.5 中專門指定了裡 specie 的型別,如果不指定,該欄位就會預設成為資料庫定義的整數。
另外,只要謹慎選擇 code 2.3 中 LnStarship.crew 這一關係的載入方式(如我們現在使用的 lazy='select'
),就可以避免無謂的資料庫查詢。比如現有一個查詢星艦的語句不需要 crew 屬性,那整個執行過程當中,都不會發生 Crew 那張表的 select 。這一點也是 GraphQL 帶來的好處之一。
Flask 整合
完成了定義和底部資料層的整合,下面要做的就是將 GraphQL Schema 接入一個服務讓客戶端可以訪問,如果 web 應用使用 Flask ,那可以非常簡單地通過 Flask-GraphQL 來完成,僅需 2 行程式碼:
from flask_graphql import GraphQLView
app.add_url_rule('/graphql', view_func=GraphQLView.as_view('graphql', schema=schema, graphiql=True))
code 3.1
其中 app
就是 Flask APP ,'/graphql' 是指定的 url 入口,GraphQLView.as_view
會產生一個 Flask 的 view function (實在不知道怎麼翻譯好),負責提供所有響應請求的方法,schema
當然就是我們之前定義好的 GraphQL Schema ,graphiql
引數指定了是否使用瀏覽器 GraphQL 互動 IDE - GraphiQL ,也就是我在 fig 1.1 ,fig 2.1 中展示的工具。
現在就可以通過 Flask 啟動的主機地址和埠來訪問 GraphQL 的服務了,假設 Flask 應用啟在 localhost:8080
上,那麼只要為客戶端配置一個入口: localhost:8080/graphql
。如果打開了 GraphiQL 的支援,那用瀏覽器直接訪問,就可以快速地與服務端進行互動,快速驗證程式碼。
剩下的工作
到這裡我們的實現還不完全,比如 Starship 的欄位 crewNum
就沒有。因為這是一個可推導欄位,所以把它設計成「不存在資料庫中」而是「根據真正 crew
的長度來實時計算」的一個量。請有興趣的讀者自己思考一下如何實現,有幾種實現方式,每種方式的優劣是什麼,各自對資料庫負載和程式碼結構都有怎樣的影響。
另外也請有興趣自己動手試試的讀者一定要熟悉 GraphiQL 的使用,可以有效提高開發的效率。現在都不需要自己啟動服務,GraphiQL 的作者為我們提供了快速體驗的入口:https://graphql.github.io/swapi-graphql/ 。進入頁面後點擊右上 Docs
瞭解整個 Schema 的詳情。這有一個地方值得注意,該例中的 Schema 使用了 Relay 的一些概念,比如 nodes 和 connection,如果覺得有些迷惑可以先閱讀一下相關資料,我們也會在後續文章中介紹。
一些坑和需要注意的地方
使用 GraphQL 開發服務端 API 的過程總體比較順利,但也有不少需要當心的地方和坑,最後為讀者們稍微介紹下。
錯誤處理
當查詢語句出錯或部分出錯時,GraphQL 不會將錯誤直接上拋造成伺服器 500 錯誤,而是依然會返回一個 json 物件,只是在這個物件中描述了發生怎樣的錯誤。這是 GraphQL 的設計哲學,只是和常見的依賴伺服器狀態碼的錯誤處理方式略有不同,在一開始會比較不習慣。
處理帶檔案的請求
GraphQL 的請求本質是一個 body 裡裝了一個查詢語句的 POST 請求,所以需要一些額外的處理才能支援 multipart 請求,比如使用中介軟體,客戶端的網路介面也需要自定義。所以我們採取的方法是把上傳圖片獨立到單獨的 API,GraphQL 請求中已經是一個可用的 url 了。
當然這麼做也有不好的地方,比如會改動使用者的使用體驗、需要額外的 UI/UX 在應對各種錯誤,但基本是一個比較平衡工作量和效果的方案。
SQLAlchemy 整合帶來的掌控性的缺失
將資料庫定義完全繫結到 GraphQL Schema 固然可以減少很多工作量,但如果我們需要一個更高階、更定製化的查詢,那就還是要自己實現 resolve 方法。同時開發者對於 SQLAlchemy 的 session 的生命週期、具體資料庫查詢語句的執行的掌握也可能變弱,造成一些潛在的效能問題。這點就需要我們在開發、測試的時候多留心。
個人經驗是我會在開發過程中開啟 SQLAlchemy engine 的 echo
屬性,然後監控查詢操作產生的每一句 SQL 語句,以瞭解實際產生的語句是否合理、是否產生了額外的資料庫查詢等。
監控的細分
以往我們可以按 API 監控伺服器效能和負載,現在整個 GraphQL 只有一個入口,那監控這個 API 入口的時間就沒有意義了。想要做好監控就需要一些額外的工作,比如一些 decorator 用於跟蹤每個 resolver 行為的效率。
潛在的安全風險和隱私風險
GraphQL 提供的只是一套支援查詢語句的 API ,而具體查詢什麼都是由客戶端指定的。那就有可能有攻擊者通過編寫一些特殊的查詢語句對伺服器進行攻擊,這些語句通常都是層數很深或請求資料的量很大,給伺服器短時間內造成巨大負擔達到拒絕服務的攻擊效果。一般解決方法是限制查詢的深度以及資料獲取大小,同時對請求的發起者要有必要的身份認證。
另外由於服務端能提供的欄位名稱是完全告知客戶端的,如果一個不小心也會洩露隱私資料,尤其是使用 SQLAlchemy 整合的時候,如果把資料庫最底層的欄位全都直接暴露給外部是非常危險的。SQLAlchemyObjectType 的 Meta 類支援通過 exclude_fields
屬性指定不向客戶端開放的欄位。另外在對敏感資料做定義時,需要團隊內部做好隱私審查。
結語
關於如何在服務端搭建一個簡單的 GraphQL 服務就說到這裡,下次有機會我們會聊一下 GraphQL 的客戶端和在 RN 中的使用。歡迎大家繼續關注,對於本文中的內容也歡迎指正。