iT邦幫忙

2025 iThome 鐵人賽

DAY 20
0

這次要來分享的是之前實作過的一個比較複雜的介面:看版,以及在裡面我們是如何用 Effect 協助我們取得資料的

什麼是看版


(圖片來源 wiki)

如果你平常有在跑敏捷跑 scrum ,可能對看版這個東西不陌生,基本上就是一個分成了多欄的頁面,每欄上面都會有卡片,每張卡片代表著一個任務,然而這個看版可以用來管理的可不只是軟體開發的任務,而是任何不同狀態的東西都可以拿來管理,比如:寫鐵人賽文章的進度,面試的進度等等的

故事時間

我以前做過的產品中,有個用看版來管理進度的功能,那時候的看版每一欄都要可以各別的排序、過濾,還要可以無限捲動,而這些都還需要靠後端 API,簡單來說,就是超級麻煩

那時候我們採用的設計是使用 typesense 這個開源的搜尋資料庫來存放並提供卡片的資料,但它並不是我們的最主要的資料存放位置,那時候大概是類似這樣的一個架構

Backend -> Main DB: 主要存放
Backend -> typesense: 複製一份存放

Main DB: "RDB" {
  shape: cylinder
}

typesense: typesense {
  shape: cylinder
}

那時候前端儲存資料到後端,後端會先放一份到主要的 RDB ,之後再同步一份到 typesense , typesense 的查詢速度很快,但寫入加上後端的同步的延遲,使得資料在 typesense 可以查詢到時都會有一定的延遲,如果要做到像 dnd-kit 的範例 這樣流暢的體驗,等待 API 的同步就不太可能了

不過以上是單純的經驗分享,這次的主要問題是,我們要如何處理在設計上我們有的

  1. 全域的 filter
  2. 每欄各別的排序與 filter

好消息是, 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 吧,我們有三欄

  • todo
  • in-progress
  • done
    然後的搜尋跟找出 assignee 的 UI ,像以下的線稿

![[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 元件,則分別查詢兩個不同的東西, titleassignee ,所以我們第一欄的完整的 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 中如何進行資源管理


上一篇
18. Request and batching
下一篇
20. Effect 資源管理與作用域
系列文
Effect 魔法:打造堅不可摧的應用程式22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言