這篇要來看 Effect 在後端又可以怎麼樣的使用,這次我們會搭配 orpc 這個 RPC 的套件來一起使用, orpc 可以幫助我們寫出 type-safety 的 API ,同時還有對各種前後端都很好的支援,如果你對 orpc 本身有興趣,也可以看看我放在 substack 上的文章介紹怎麼用 orpc 搭配 OpenAPI spec
還記得之前提過我有在我自己的開源專案使用 Effect ,使用自訂的 runtime 共享一個 mutex 嗎?這次就來介紹這個部份吧,我們來寫個 todo app 當範例,雖然 todo app 聽起來好像有點老套,但我個人認為它是個很好的題目用來展示前後端的功能
這次的設計目標是一個可以將 todo 存在後端的 app ,我們會刻意的單純使用檔案做為儲存的方法
這次完整的程式碼範例在 https://github.com/DanSnow/ithelp-2025-ironman-sample-codes/tree/main/effect-todo-app
這次的專案是使用 create-start-app 建立的 TanStack Start 的專案,建立時記得選 tailwindcss, shadcn, tanstack query 與這次的主角之一 orpc ,這樣你就會很快的有個專案,而且已經內建了一個 todo app 的範例了,不過 todo 列表是存在記憶體中的,重開就不見了,我們先改成用檔案儲存吧
首先先把檔案的存取,還有 todo 的操作都包裝成 service ,首先是檔案的存取,也就是持久化的部份
// src/services/StorageService.ts
import { readFile, writeFile } from "node:fs/promises";
import { Effect, pipe } from "effect";
import { z } from "zod";
import { TodoSchema } from "@/orpc/schema";
const TodosSchema = z.array(TodoSchema);
const FILE_NAME = "todos.json";
type Todos = z.infer<typeof TodosSchema>;
export class StorageService extends Effect.Service<StorageService>()(
"Storage",
{
accessors: true,
effect: Effect.gen(function* () {
return {
loadTodos: pipe(
// 讀取檔案
Effect.tryPromise(() => readFile(FILE_NAME, "utf-8")),
// 這邊使用的是 tryMap ,簡單來說就是 try + map 的合體
Effect.tryMap({
try: (content) => {
const json = JSON.parse(content);
return TodosSchema.parse(json);
},
catch: (error) => error as Error,
}),
// 若有任何的錯誤就回傳空陣列,包含檔案找不到,格式不對等
Effect.catchAll(() => Effect.succeed([])),
),
// 寫入 todo
saveTodos: (todos: Todos) =>
Effect.promise(() => writeFile(FILE_NAME, JSON.stringify(todos))),
};
}),
},
) {}
檔案處理的部份已經包好一個 service 了,再來是比較高階的操作的 service
// src/services/TodoService.ts
import { Array, Effect, pipe } from "effect";
import type { Todo } from "@/orpc/schema";
import { StorageService } from "./StorageService";
export class TodoService extends Effect.Service<TodoService>()("Todo", {
accessors: true,
dependencies: [StorageService.Default],
effect: Effect.gen(function* () {
const { loadTodos, saveTodos } = yield* StorageService;
return {
todos: loadTodos,
// 讀取檔案,增加 todo 後再寫回去
addTodo: (name: string) =>
pipe(
loadTodos,
Effect.map((todos): Todo[] =>
Array.append(todos, {
id: todos.length,
name,
}),
),
Effect.flatMap((todos) => saveTodos(todos)),
),
};
}),
}) {}
再來我們準備後端用的 runtime
// src/runtime.ts
import { ManagedRuntime } from "effect";
import { TodoService } from "./services/TodoService";
export const BackendRuntime = ManagedRuntime.make(TodoService.Default);
最後將 orpc 內的 handler 換成 todo 的操作方法就行了
// src/orpc/router/todos.ts
import { os } from "@orpc/server";
import { z } from "zod";
import { BackendRuntime } from "@/runtime";
import { TodoService } from "@/services/TodoService";
export const listTodos = os.input(z.object({})).handler(() => {
return BackendRuntime.runPromise(TodoService.todos);
});
export const addTodo = os
.input(z.object({ name: z.string() }))
.handler(({ input }) => {
return BackendRuntime.runPromise(TodoService.addTodo(input.name));
});
到這邊就完成了,話說這邊你可以看到 orpc 是如何定義 API 的,我們可以指定輸入與使用 zod 驗證輸入的參數,我們可以看一下範例內的 UI 是怎麼串接後端的
// src/routes/index.tsx
// 以下是位於 component 中的部份程式碼
const { data, refetch } = useQuery(
orpc.listTodos.queryOptions({
input: {},
}),
);
const [todo, setTodo] = useState("");
const { mutate: addTodo } = useMutation(
orpc.addTodo.mutationOptions({
onSuccess: () => {
refetch();
setTodo("");
},
}),
);
const submitTodo = useCallback(() => {
addTodo({ name: todo });
}, [addTodo, todo]);
這邊是使用 orpc 與 tanstack query ,你可以看到我們可以直接用 orpc 自動產生的 queryOptions
與 mutationOptions
來提供給 tanstack query ,而且這邊也都有完整的型態檢查,這讓我們的 API 呼叫跟自己寫 fetch
沒有型態檢查等等的比起來安全的很多
看完了我們就可以直接用範例內的 UI 操作看看了,不過目前的版本有個問題,你有看出來嗎?
在範例中,我們使用了檔案做為儲存的媒介,但這造成了一個問題,那就是我們的 addTodo 不是原子的操作,也就是說如果同時執行兩次 addTodo 的話,有可能會發生競爭狀態,造致檔案中的內容不是我們預期的,我們實際來測試看看吧
測試需要同時發送多個 http request ,我們直接使用 http 的 benchmark 工具 oha 幫我們做到吧,安裝後執行以下指令, oha 預設會發送 200 個 request ,這已經足夠我們測試用了
$ oha -m POST -d '{"json":{"name":"hello"}}' -T 'application/json' 'http://localhost:3000/api/rpc/addTodo'
然後我們去看看 todos.json
,理論上裡面應該要有 200 個 todo ,但實際上:
[{"id":0,"name":"hello"}]
沒錯!只有一個,我們來看怎麼處理吧
這邊我們使用鎖來幫檔案存取的部份加上保護, Effect 其實就內建了 Semaphore 可以使用, Seamphore 是一種可以設定最多允許 n 個操作同步進行的同步機制,當一個動作開始進行時,就從 Semaphore 中減 1 ,完成時加 1 ,若 Semaphore 為 0 時則需要等待其它的操作完成
我們平常常看到的 mutex 可以當作是一種只允許 1 個操作的特殊 Semaphore ,不過 Effect 裡只有 Semaphore 我們就拿來用了,要使用很簡單:
// src/services/TodoService.ts
// 這段是 service 內的程式碼
// 使用 Effect.makeSemaphore 建立一個 Semaphore ,這個 Seamphore 只允許 1 個操作
const mutex = yield* Effect.makeSemaphore(1);
return {
todos: loadTodos,
addTodo: (name: string) =>
pipe(
loadTodos,
Effect.map((todos): Todo[] =>
Array.append(todos, {
id: todos.length,
name,
}),
),
Effect.flatMap((todos) => saveTodos(todos)),
// 指定這個 Effect 需要從 Seamphore 扣除 1 ,這樣就能保證同時只會有一個在執行了
mutex.withPermits(1),
),
};
我們到這邊可以再執行一次 oha 確認資料是不是真的有正常的變成 200 筆,那我們這次的實作就到這邊了,不過 Effect 帶來的好處可不是只有很好設定與使用 Seamphore 而已,在上面我們還將存取的動作與 todo 的操作都拆成 service ,這帶來的好處有:
這些在後端都可以增加程式的正確與穩定性,下一篇要來介紹的是 log