查詢通常會涉及三個主體:Entity, Attribute, Value,如果將這三者類比到關聯式資料庫的話則分別是 Table, Column, Record,下表舉三個最基本的查詢為例:
V ← (E, A) | 查詢保單的生效日 |
E ← (A, V) | 查詢車牌號碼等於5566的保單 |
A ← (E, V) | 查詢 A, B, C, ... 等保單屬性中 display 為 True 者 |
如果存在一套 Query Language 可以表達所有種類的 Query,則可稱此 Query Language 具有完備性(Query Complete)。
討論程式語言的可計算性時,經常會使用圖靈完備性2(Turing Completeness)來描述某程式語言是否能夠表達所有種類的運算,因此本文借用相同概念延伸至 Query Language。
如果我們把所有種類的 Query 視為 Entity, Attribute, Value 與 CRUD 四大操作的排列組合,便能粗略地歸納 Query Language 的完備性,下表舉 MongoDB Query Language(MQL)及 GraphQL 為例:
Create Entity | ✔️ | ✔️ |
Read Entity | ✔️ | ✔️ |
Update Entity | ✔️ | ✔️ |
Delete Entity | ✔️ | ✔️ |
Create Attribute | ✔️ | ✔️ |
Read Attribute | ✔️ | ✔️ |
Update Attribute | ✔️ | ✔️ |
Delete Attribute | ✔️ | ✔️ |
Create Value | ✔️ | ✔️ |
Read Value | ✔️ | ✔️ |
Update Value | ✔️ | ✔️ |
Delete Value | ✔️ | ✔️ |
筆者之所以有機會撰寫此文,是因為 OneDegree 的產品之一 近期需要開發一套強而有力的搜尋引擎,就姑且稱做 吧!然而系統現存的 Legacy API 形式(下稱 )不夠完備,因此已無法滿足最新的需求了。
讓我們以車險為例子,具體來說,假設系統中存在兩張車險保單(Policy),其中被保標的(InsuredSubject)分別具有不同的屬性(ExtraDataAttributeValue):
policy_1 = Policy( insured_subjects=[ InsuredSubject( extra_data_attribute_values=[ ExtraDataAttributeValue( attribute_name='car_age', numeric_value=10 ), ExtraDataAttributeValue( attribute_name='car_vendor', string_value='BMW' ) ] ) ])policy_2 = Policy( insured_subjects=[ InsuredSubject( extra_data_attribute_values=[ ExtraDataAttributeValue( attribute_name='car_age', numeric_value=10 ), ExtraDataAttributeValue( attribute_name='car_plate', string_value='OD5566' ) ] ) ])
當後台人員在 Advanced Filter 搜尋 且 的保單時,我們應該預期只會搜尋出 ,且可能嘗試寫出類似以下的 Internal API Query:
GET /policies?fields=...&filter={ "and": [ { "and": [ {"==": ["insured_subjects.extra_data_attribute_values.attribute_name", "car_age"]}, {"==": ["insured_subjects.extra_data_attribute_values.numeric_value", 10]} ] }, { "and": [ {"==": ["insured_subjects.extra_data_attribute_values.attribute_name", "car_plate"]}, {"==": ["insured_subjects.extra_data_attribute_values.string_value", "OD5566"]} ] } ]}
你將發現其實外層的 運算元應該換成 ,或是內層遇到複數欄位時應該要能進行逐項匹配的運算,如此才能得到預期的搜尋結果。但是 Internal API 此時此刻並沒有上述的功能,因此可知 Internal API 在 Advanced Filter 所需的功能上並不完備。
如果將同樣的情況重現於 MongoDB:
db.policy.insertMany([ { "name": "policy_1", "insured_subjects": [ { "extra_data_attribute_values": [ { "attribute_name": "car_age", "numeric_value": 10 }, { "attribute_name": "car_vendor", "string_value": "BMW" } ] } ] }, { "name": "policy_2", "insured_subjects": [ { "extra_data_attribute_values": [ { "attribute_name": "car_age", "numeric_value": 10 }, { "attribute_name": "car_plate", "string_value": "OD5566" } ] } ] }])
你將發現可以透過以下 MQL 實現理想中的查詢:
db.policy.find({ "insured_subjects": { $elemMatch: { "extra_data_attribute_values": { $all: [ { $elemMatch: { "attribute_name": "car_age", "numeric_value": 10 } }, { $elemMatch: { "attribute_name": "car_plate", "string_value": "OD5566" } } ] } } }})
由於 MQL 在複數欄位上提供了 運算元讓我們能夠進行逐項匹配的運算,使得最終結果是正確的,因此可知 MQL 在 Advanced Filter 所需的功能上是完備的。
接著再看同樣的情況重現於關聯式資料庫,以 SQLAlchemy 的 ORM Query 為例:
policy_query_1 = session\ .query(Policy.id, Policy.number)\ .join(Policy.insured_subjects)\ .join(InsuredSubject.extra_data_attribute_values)\ .filter( and_( InsuredSubjectExtraDataAttributeValue.attribute_name == 'car_age', InsuredSubjectExtraDataAttributeValue.numeric_value == 10, ) )policy_query_2 = session\ .query(Policy.id, Policy.number)\ .join(Policy.insured_subjects)\ .join(InsuredSubject.extra_data_attribute_values)\ .filter( and_( InsuredSubjectExtraDataAttributeValue.attribute_name == 'car_plate', InsuredSubjectExtraDataAttributeValue.string_value == 'OD5566', ) )policies = policy_query_1.intersect(policy_query_2).all()
聰明的你應該已經知道,由於 SQL 提供了 語句使得查詢結果符合預期,因此可知 SQL 在 Advanced Filter 所需的功能上也是完備的。
考量翻修 Internal API 的巨大成本,最終與各方 Stakeholders 協議發展新的 Query 方式(下稱 ODQL),最低要求除了要滿足 Advanced Filter 的需求外,ODQL 也必須涵蓋 Internal API 既有的所有功能,並且最好順便補足先前 Internal API 曾經遇過的痛點。
就 Advanced Filter 所需的完備性而言,ODQL 僅需完成 Read Only 的部分,暫時無須考量 Create, Update 及 Delete 的操作:
Create Entity | ✔️ | ✔️ | ❌ |
Read Entity | ✔️ | ✔️ | ✔️ |
Update Entity | ✔️ | ✔️ | ❌ |
Delete Entity | ✔️ | ✔️ | ❌ |
Create Attribute | ✔️ | ✔️ | ❌ |
Read Attribute | ✔️ | ✔️ | ✔️ |
Update Attribute | ✔️ | ✔️ | ❌ |
Delete Attribute | ✔️ | ✔️ | ❌ |
Create Value | ✔️ | ✔️ | ❌ |
Read Value | ✔️ | ✔️ | ✔️ |
Update Value | ✔️ | ✔️ | ❌ |
Delete Value | ✔️ | ✔️ | ❌ |
在上方的舉例中我們已經看見 SQLAlchemy 可以滿足 Advanced Filter 這個新功能,且先前 Internal API 的實作也全然仰賴 SQLAlchemy,因此只要 ODQL 能夠完美映射成 SQLAlchemy Query,我們將既能滿足新需求,也能相容 Internal API 所能提供的任意查詢。
由於時程壓力,開發團隊沒有充足的時間重新發明 ODQL 的語法,因此優先採用目前發展非常成熟的 JSON 格式作為 Query 語法,這將能替我們節省開發 Syntax Parser 的時間。
至此,如何實作 ODQL 的問題已經被簡化成另一個等價的命題了:
如何將 SQLAlchemy 的 ORM Query 表達成 JSON 格式?
在多數程式語言裡,我們通常能將 expression 表示成一顆樹狀的結構,稱為 Expression Tree。而 Tree 的一大好處在於我們可以很容易使用 JSON 的形式來表現,因此我們的原命題可以再次修正為:
如何將 SQLAlchemy 的 ORM Query 表達成 Expression Tree?
如果你對 SQLAlchemy Query 足夠熟悉,應該可以知道它的運算元包含了 , , , , , , , , , , , , , ...,按照運算子的類型分類後便能更加一目瞭然:
因此我們便能夠定義出 ODQL 的 Operator Set 以及 Operands 彼此間的 Expression Tree 範式了:
entity_level_query = { <entity_name>: column_level_query } | { <entity_level_operator>: *(operands ∪ entity_level_queries) }column_level_query = { <column_name>: { <column_level_operator>: *operands } | column_level_query } | { <binary_operator>: *column_level_queries }
例如:
{ "offset()": [ 666, { "intersect()": [ { "policies": { "and_()": [ { "is_latest": { "==": true } }, { "effective_time": { ">=": "2021-11-05 00:00:00+00:00" } } ] } }, { "policies": { "not_()": [ { "product": { "state": { "==": "launched" } } } ] } } ] } ]}
目前為止,雖然我們已經滿足了查詢條件的語法,但是使用者還沒辦法指定最終要取得的欄位,因此必須再次變更範式的設計,納入欄位選取的考量:
entity_level_query = { <entity_name>: { "fields": fields_expression "filter": column_level_query } } | { <entity_level_operator>: *(operands ∪ entity_level_queries) }
field_name = "field_name[,field_name]"fields_expression = "{field_name[fields_expression]}"
例如:
{ "policies": { "fields": "{id,name,insured_subjects{id},product{state},create_time}", "filter": ... }}
在 RESTful 的時代,如果要取得多個 Entities 下的內容,通常要連打幾支不同的 API,不僅拉長了 TTI(Time to Interactive),如果應用程式對資料一致性有特殊要求,甚至可能因為 DB 併發導致看到不一致的 Response Data。在參考 GraphQL 的設計之後,ODQL 也納入了 Multiple Entities 的查詢,讓我們再次修改範式:
cross_entity_query = { "queries": *entity_level_queries}
考慮與 FE 整合的難易度,連線上仍然採用 HTTP。但是考慮到先前 Internal API 曾經遇過 URL 長度超過 Gunicorn 的預設限制(),且 ODQL 雖然現階段只有 Read Only,但未來不排除實作 Update & Delete Entities,因此 HTTP Verb 最終選擇了 POST,fields 及 query 則帶在 Request Body:
POST /odql <cross_entity_query>
完整實作涉及公司機密,就憑各位讀者的造化了。
ODQL 並非萬靈丹,我們開發它只是為了滿足大部分日常需求,雖然它的確補足了 Internal API 的一些不便,卻也產生了需要取捨的成本,以下總結了幾項利弊,讓大家更曉得使用新工具的同時有哪些仍需注意的事項。
優點:
缺點:
目前在 OneDegree 內部以使用 React 為主,因此與 FE 的整合上建議以 React Hook 的介面來串接最為乾淨俐落,且 ODQL 仍為實驗性質的嘗試,不宜與專案建立過多耦合,保持 Functional 的寫法仍是上策。
初期使用上建議以撰寫 Raw Query 為主:
const MyComponent = () => { const { countries, timezones } = useODQL({ 'countries': { 'fields': '{id}', 'filter': {...} }, 'timezones': { 'fields': '{id}', 'filter': {...} } })
return ( ... )}
使用上較為穩定成熟後可以考慮簡易封裝,例如 Builder Pattern:
const MyComponent = () => { const { QueryBuilder } = useODQL() policies = QueryBuilder() .intersect_with_queries([ QueryBuilder() .set_entity('policies') .set_fields('{id,number,insured_subjects{id,name}}') .set_filter({ 'is_latest': { '$eq': true } }) .build(), QueryBuilder() .set_entity('policies') .set_fields('{id,number,insured_subjects{id,name}}') .set_filter({ 'create_time': { '$gt': '2021-11-07 00:00:00+00:00' } }) .build() ]) .set_offset(0) .set_limit(10) .build() .fetch()
return ( ... )}