更新於 2023/07/15閱讀時間約 6 分鐘

閒談軟體設計:Query Object

前言

之前在開發旅遊 app 的內容管理平台時,初期 Repository 用得好好的,但到後期,為了輸出報表或是提供特定列表時,就需要一直在 repository 的介面上加上特定的 query method,像是這樣 (這不是當時的程式,只是示意):
一下是取得某份文件所有語系副本,一下又是取得某文件的特定語係副本,又或者是取得某份文件參考到的其他文件,就這樣,DocumentRepository 的 method 越來越多。
當時根據資料 scale 和特性,用不同的資料庫儲存,像是比較類似 metadata 的資料,例如帳號、權限管理,使用傳統的 RDBMS 儲存,資料量會比較龐大,沒什麼結構的資料則是用 NoSQL 的儲存,這時 repository 的抽象層確實提供一個好處,business logic 並不需要知道實際資料放在何處,操作的方式在換資料庫後也完全不用變。

查詢條件物件化

說是這麼說啦,但 repository 內部的實作就有點頭痛了,同樣的查詢條件,在換資料庫後得重寫,於是我決定替 Repository 加入一個 QueryDesciptor 物件描述查詢條件,說真的,那時還沒有看過《Patterns of Enterprise Application Architecture》的 Query Object (這本書一直沒全部看完),當初的動機很單純,只是想包裝 Hibernate 的 Criteria API,可以用類似的物件導向語法寫 query 條件,然後由 repository 轉換成底層對應的 query 方式,如此 business logic layer 不需要根據不同的資料庫寫不同的 query。
因此上面例子中的 DocumentRepository 就不再需要多加三個 method,而是在 GenericRepository 中加入對 QueryDescriptor 的支援,為什麼 method 要叫 filter,而不是 find 或是其他的,主要是最近各大語言 (例如Swift) 的 collection 介面都把類似的行為用 filter 來完成。
這麼做之後,原本寫在MongoDocumentRepository 的 query 省下來了,太好了,少寫很多程式,ㄜ~ QueryDescriptor 沒那麼神,只是變成 query 搬到 DocumentManager 的實作中 (例子中的 originId、language 和 referedId 是 Document 物件的 property 名稱,不是 database 的 column 名稱):
也就是說,假設要換資料庫,只要注入 (inject) 不同的 repository 實作,邏輯是不需要更改的,聽起來很棒,但當初實作 QueryDescriptor 對 Hibernate Criteria 及對 MongoDB 搜尋條件的轉換時吃了不少苦頭,足足花上了一個禮拜的時間,而且還是最簡單的版本。
若不考慮 DSL 的寫法,可能會快一點,但為了讓用起來很像在寫 query language,得花上不少時間包裝,where 後面能接 is、in、like、greatThan、 lessThan、 greatThenOrEqual、lessThanOrEqual 等常見條件,加上 all 和 any 設定條件是全部滿足 (AND) 或是任一滿足 (OR),在 aggregation 的部分只有 min、max 和 count,最後再加上 sort、from 和 size 處理分頁的問題,projection 當時沒用到,就沒有加進去。
Figure 1 - Query object and the visitor
Figure 1 - Query object and the visitor
內部其實是一個 Query 的 AST 樹狀結構 (上述很多 method 只是建立一個物件然後呼叫 add 加到目前的節點), 當時轉換時是寫個 Visitor 走遍這個 AST 然後根據當下的節點產生對應的物件,Hibernate 應該是較簡單的,像是 is 其實是用 Restrictions.eq(name, value) 產生一個 SimpleExpression 物件,在處理 MongoDB 時就比較討厭一點,不過最花時間的,是測試,畢竟轉換出錯,撈出來的資料就會是錯的,測試案例寫了不少。
至於花這麼多時間真的值得嗎?畢竟有原生的 API 可以做, 特別是如果有用 Spring Data JPA 搭配 annotation,只要繼承 Spring Data 的 Repository 然後使用 Spring 提供的 naming convention 設計 method,Spring Data 就會自動在 runtime 提供實作 (這其實真的蠻神的),但我自己覺得值得
稍微複雜一點的 query 其實代表著某些商業邏輯,為了避免相依 (這時候不在意相依的人就開心了,對吧~在意這麼多幹嘛,直接在 business logic layer 用第三方 API 寫 query 就好啦),必須把這一段程式 push 到 repository 的實作層,會變成這些商業邏輯被隱藏起來了,如果有個好的描述語言,像剛剛 DefaultDocumentManager 的例子,我倒覺得很好讀,也可以清楚知道背後的商業邏輯是什麼,是很好的一件事。
只是沒想到最近在整理一些 Repository 的心得時,再次拿起《Patterns of Enterprise Application Architecture》來看時,才發現原來這是一個稱作 Query Object 的 pattern,而 Hibernate Criteria 或是 MongoDB 的 Filters 都是各家廠商在他們自己產品中提供的 Query Object,我做的只是透過抽象避免 vendor lock-in 而已。

結語

最後,以 PoEAA 書中對 Query Object 的描述來總結
An object that represents a database query.
本來這一篇是要放入閒談軟體設計:Repository 的內容,但覺得有點長,於是獨立出來了!
分享至
成為作者繼續創作的動力吧!
© 2024 vocus All rights reserved.