這次要來分享的是之前實作過的一個比較複雜的介面:看版,以及在裡面我們是如何用 Effect 協助我們取得資料的
如果你平常有在跑敏捷跑 scrum ,可能對看版這個東西不陌生,基本上就是一個分成了多欄的頁面,每欄上面都會有卡片,每張卡片代表著一個任務,然而這個看版可以用來管理的可不只是軟體開發的任務,而是任何不同狀態的東西都可以拿來管理,比如:寫鐵人賽文章的進度,面試的進度等等的
我以前做過的產品中,有個用看版來管理進度的功能,那時候的看版每一欄都要可以各別的排序、過濾,還要可以無限捲動,而這些都還需要靠後端 API,簡單來說,就是超級麻煩
那時候我們採用的設計是使用 typesense 這個開源的搜尋資料庫來存放並提供卡片的資料,但它並不是我們的最主要的資料存放位置,那時候大概是類似這樣的一個架構
Backend -> Main DB: 主要存放
Backend -> typesense: 複製一份存放
Main DB: "RDB" {
shape: cylinder
}
typesense: typesense {
shape: cylinder
}
那時候前端儲存資料到後端,後端會先放一份到主要的 RDB ,之後再同步一份到 typesense , typesense 的查詢速度很快,但寫入加上後端的同步的延遲,使得資料在 typesense 可以查詢到時都會有一定的延遲,如果要做到像 dnd-kit 的範例 這樣流暢的體驗,等待 API 的同步就不太可能了
不過以上是單純的經驗分享,這次的主要問題是,我們要如何處理在設計上我們有的
好消息是, typesense 的查詢能力非常的強,例如你可能可以用以下的 query 來查出在 tasks
這張資料表 (typesense 中稱為 collection) 中狀態為 done
,含有 foo
的資料,且照字母排序
client.multiSearch.perform({
searches: [
{
collection: "tasks",
q: "foo",
query_by: "title",
filter_by: "status:done",
sort_by: "title:asc",
},
],
});
話說你有注意到嗎? typesense 傳入的查詢是一個陣列喔,代表說它一次可以進行多個查詢,這代表我們可以用 batch
這次的目標就假設是開發一個像平常管理開發任務那樣的 jira board 吧,我們有三欄
![[kanba-mock-ui.excalidraw]]
而我們的 task 的資料定義為這樣的
interface Task {
title: string;
status: "todo" | "in-progress" | "done";
assignee: string
created_at: number;
}
在上面的 UI 中,每欄都有一個各別且固定的 filter 為 status
,例如第一欄就是 status:todo
,而上面的兩個 UI 元件,則分別查詢兩個不同的東西, title
與 assignee
,所以我們第一欄的完整的 query 可能會長的像這樣
client.multiSearch.perform({
searches: [
{
collection: "tasks",
q: "query",
query_by: "title",
filter_by: "status:todo,assignee:Bob",
sort_by: "title:asc",
},
],
});
而今天要取的這個資料,我們需要的參數就有三個,會是如下
interface QueryInput {
title: string;
assignee: string | null;
status: "todo" | "in-progress" | "done";
}
我們將這個定義為 request
class QueryRequest extends Request.TaggedClass("Query")<
Task[],
Error,
QueryInput
> {}
然後我們來準備個 resolver 吧,首先我們要先把 QueryRequest
轉換成 typesense 的 request 格式
import { Array, pipe } from 'effect'
// 如果你使用的 library 像 typesense 這樣很遺憾的都沒把重要的 type export 出來的話
// 你只能像這樣靠自己取得自己需要的 type 了,這算是個小技巧
type MultiSearchRequest = Parameters<
typeof client.multiSearch.perform
>[0]["searches"][number];
// 將 input 轉換成 filter query
function toFilterString(input: QueryInput) {
const filters = [`status:${input.status}`];
if (input.assignee) {
filters.push(`assignee:${input.assignee}`);
}
return filters.join(",");
}
// 將 query 都轉換成 typesense 的 request
function convertToSearchRequest(inputs: QueryInput[]): MultiSearchRequest[] {
return pipe(
inputs,
Array.map(
(request): MultiSearchRequest => ({
collection: "tasks",
q: request.title ? request.title : "*",
query_by: "title",
filter_by: toFilterString(request),
sort_by: "title:asc",
})
)
);
}
再來就是重點了,我們要來準備之前提到過的 batch request 的 resolver
const TypesenseResolver = RequestResolver.makeBatched(
(requests: QueryRequest[]) =>
pipe(
Effect.tryPromise({
try: () =>
// 使用 typesense 的 API 進行 batch 查詢
client.multiSearch.perform<Task[]>({
searches: convertToSearchRequest(requests),
}),
catch: (error) => error as Error,
}),
Effect.flatMap((result) =>
Effect.forEach(result.results, (response, index) =>
// 成功完成 request
Request.completeEffect(
requests[index],
Effect.succeed((response.hits ?? []).map((hit) => hit.document))
)
)
),
Effect.catchAll((error) =>
// 失敗的話將 request 標記為失敗
Effect.forEach(requests, (request) =>
Request.completeEffect(request, Effect.fail(error))
)
)
)
);
再來是準備 helper function
function getTasks(input: QueryInput) {
return Effect.request(new QueryRequest(input), TypesenseResolver);
}
最後,每當使用者更新了 search 的內容,或是更動了 assignee 的 dropdown 時,我們就可以像這樣查詢了
interface BoardTasks {
todo: Task[];
inProgress: Task[];
done: Task[];
}
function getBoardTasks(
input: Omit<QueryInput, "status">
): Effect.Effect<BoardTasks, Error> {
return Effect.all(
{
todo: getTasks({
...input,
status: "todo",
}),
inProgress: getTasks({
...input,
status: "in-progress",
}),
done: getTasks({
...input,
status: "done",
}),
},
{ concurrency: "unbounded", batching: true }
);
}
就這樣,這篇我們以實際的案例分享了 Effect 中的 batch request 可以如何實用,在下一篇我們來看在 Effect 中如何進行資源管理