前言
最近Deno正式版已經發布了。有的程式設計師說Deno可以完全取代Node.js,有的程式設計師說比Node.js 有優勢,眾說風雲。不管三七二十一,我們一起了解了解。對 Deno 還不了解的讀者,建議先閱讀本文文“「乾貨」通俗易懂的Deno 入門教程” 這篇文章。
一、Oak 簡介相信接觸過 Node.js 的讀者對 Express、Hapi、Koa 這些 Web 應用開發框架都不會陌生,在 Deno 平臺中如果你也想做 Web 應用開發,可以考慮直接使用以下現成的框架:
deno-drash:A REST microframework for Deno with zero dependencies。deno-express:Node Express way for Deno。oak:A middleware framework for Deno's net server 。pogo:Server framework for Deno。servest:A progressive http server for Deno。寫作本文時,目前 Star 數最高的專案是 Oak,加上我的一個 Star,剛好 720。下面我們來簡單介紹一下 Oak:
A middleware framework for Deno's http server, including a router middleware.
This middleware framework is inspired by Koa and middleware router inspired by koa-router.
很顯然 Oak 的的靈感來自於 Koa,而路由中介軟體的靈感來源於 koa-router 這個庫。如果你以前使用過 Koa 的話,相信你會很容易上手 Oak。不信的話,我們來看個示例:
import { Application } from "https://deno.land/x/oak/mod.ts";const app = new Application();app.use((ctx) => { ctx.response.body = "Hello Semlinker!";});await app.listen({ port: 8000 });
以上示例對於每個 HTTP 請求,都會響應 "Hello Semlinker!"。只有一箇中間件是不是感覺太 easy 了,下面我們來看一個更復雜的示例(使用多箇中間件):
import { Application } from "https://deno.land/x/oak/mod.ts";const app = new Application();// Loggerapp.use(async (ctx, next) => { await next(); const rt = ctx.response.headers.get("X-Response-Time"); console.log(`${ctx.request.method} ${ctx.request.url} - ${rt}`);});// Timingapp.use(async (ctx, next) => { const start = Date.now(); await next(); const ms = Date.now() - start; ctx.response.headers.set("X-Response-Time", `${ms}ms`);});// Hello World!app.use((ctx) => { ctx.response.body = "Hello World!";});await app.listen({ port: 8000 });
為了更好地理解 Oak 中介軟體流程控制,我們來一起回顧一下 Koa 大名鼎鼎的 “洋蔥模型”:
koa-onion-model
從 “洋蔥模型” 示例圖中我們可以很清晰的看到一個請求從外到裡一層一層的經過中介軟體,響應時從裡到外一層一層的經過中介軟體。
上述程式碼成功執行後,我們開啟瀏覽器,然後訪問 http://localhost:8000/URL 地址,之後在控制檯會輸出以下結果:
➜ learn-deno deno run --allow-net oak/oak-middlewares-demo.tsGET http://localhost:8000/ - 0msGET http://localhost:8000/favicon.ico - 0ms
好了,介紹完 Oak 的基本使用,接下來我們開始進入正題,即使用 Oak 開發 REST API。
二、Oak 實戰本章節我們將介紹如何使用 Oak 來開發一個 Todo REST API,它支援以下功能:
新增新的 Todo顯示 Todo 列表獲取指定 Todo 的詳情移除指定 Todo更新指定 Todo小夥伴們,你們準備好了沒?讓我們一起步入 Oak 的世界!
步驟一:初始化專案結構首先我們在 learn-deno 專案中,建立一個新的 todos 目錄,然後分別建立以下子目錄和 TS 檔案:
handlers 目錄: 存放路由處理器;middlewares 目錄: 存放中介軟體,用於處理每個請求;models 目錄: 存放模型定義,在我們的示例中只包含 Todo 介面;services 目錄: 存放服務層程式;db 目錄:作為本地資料庫,存放 Todo 資料;config.ts:包含應用的全域性配置資訊;index.ts :應用的入口檔案;routing.ts:包含 API 路由資訊。完成專案初始化之後,todos 專案的目錄結構如下所示:
└── todos ├── config.ts ├── db ├── handlers ├── index.ts ├── middlewares ├── models ├── routing.ts └── services
如你所見,這個目錄結構看起來像一個小型 Node.js Web 應用程式。下一步,我們來建立 Todo 專案的入口檔案。
步驟二:建立入口檔案index.ts
import { Application } from "https://deno.land/x/oak/mod.ts";import { APP_HOST, APP_PORT } from "./config.ts";import router from "./routing.ts";import notFound from "./handlers/notFound.ts";import errorMiddleware from "./middlewares/error.ts";const app = new Application();app.use(errorMiddleware);app.use(router.routes());app.use(router.allowedMethods());app.use(notFound);console.log(`Listening on ${APP_PORT}...`);await app.listen(`${APP_HOST}:${APP_PORT}`);
在第一行程式碼中,我們使用了 Deno 所提供的功能特性,即直接從網路上匯入模組。除此之外,這裡沒有什麼特別的。我們建立一個應用程式,新增中介軟體,路由,最後啟動伺服器。整個流程就像開發普通的 Express/Koa 應用程式一樣。
步驟三:建立配置檔案config.ts
const env = Deno.env.toObject();export const APP_HOST = env.APP_HOST || "127.0.0.1";export const APP_PORT = env.APP_PORT || 3000;export const DB_PATH = env.DB_PATH || "./db/todos.json";
為了提高專案的靈活性,我們支援從環境中讀取配置資訊,同時我們也為每個配置專案提供了相應的預設值。其中 Deno.env() 相當於Node.js 平臺中的 process.env。
步驟四:新增 Todo 模型models/todo.ts
export interface Todo { id: number; userId: number; title: string; completed: boolean;}
在 Todo 模型中,我們定義了 id、userId、title 和 completed 四個屬性,分別表示 todo 編號、使用者編號、todo 標題和 todo 完成狀態。
步驟五:新增路由routing.ts
import { Router } from "https://deno.land/x/oak/mod.ts";import getTodos from "./handlers/getTodos.ts";import getTodoDetail from "./handlers/getTodoDetail.ts";import createTodo from "./handlers/createTodo.ts";import updateTodo from "./handlers/updateTodo.ts";import deleteTodo from "./handlers/deleteTodo.ts";const router = new Router();router .get("/todos", getTodos) .get("/todos/:id", getTodoDetail) .post("/todos", createTodo) .put("/todos/:id", updateTodo) .delete("/todos/:id", deleteTodo);export default router;
同樣,沒有什麼特別的,我們建立一個 router 並新增 routes。它看起來幾乎與 Express.js 應用程式一模一樣。
步驟六:新增路由處理器handlers/getTodos.ts
import { Response } from "https://deno.land/x/oak/mod.ts";import { getTodos } from "../services/todos.ts";export default async ({ response }: { response: Response }) => { response.body = await getTodos();};
getTodos 處理器用於返回所有的 Todo。如果你從未使用過 Koa,則 response 物件類似於 Express 中的 res 物件。在 Express 應用中我們會呼叫 res 物件的 json 或 send 方法來返回響應。而在 Koa/Oak 中,我們需要將響應值賦給 response.body 屬性。
handlers/getTodoDetail.ts
import { Response, RouteParams } from "https://deno.land/x/oak/mod.ts";import { getTodo } from "../services/todos.ts";export default async ({ params, response,}: { params: RouteParams; response: Response;}) => { const todoId = params.id; if (!todoId) { response.status = 400; response.body = { msg: "Invalid todo id" }; return; } const foundedTodo = await getTodo(todoId); if (!foundedTodo) { response.status = 404; response.body = { msg: `Todo with ID ${todoId} not found` }; return; } response.body = foundedTodo;};
getTodoDetail 處理器用於返回指定 id 的 Todo,如果找不到指定 id 對應的 Todo,會返回 404 和相應的錯誤訊息。
handlers/createTodo.ts
import { Request, Response } from "https://deno.land/x/oak/mod.ts";import { createTodo } from "../services/todos.ts";export default async ({ request, response,}: { request: Request; response: Response;}) => { if (!request.hasBody) { response.status = 400; response.body = { msg: "Invalid todo data" }; return; } const { value: { userId, title, completed = false }, } = await request.body(); if (!userId || !title) { response.status = 422; response.body = { msg: "Incorrect todo data. userId and title are required", }; return; } const todoId = await createTodo({ userId, title, completed }); response.body = { msg: "Todo created", todoId };};
createTodo 處理器用於建立新的 Todo,在執行新增操作前,會驗證是否缺少 userId 和 title 必填項。
handlers/updateTodo.ts
import { Request, Response } from "https://deno.land/x/oak/mod.ts";import { updateTodo } from "../services/todos.ts";export default async ({ params, request, response,}: { params: any; request: Request; response: Response;}) => { const todoId = params.id; if (!todoId) { response.status = 400; response.body = { msg: "Invalid todo id" }; return; } if (!request.hasBody) { response.status = 400; response.body = { msg: "Invalid todo data" }; return; } const { value: { title, completed, userId }, } = await request.body(); await updateTodo(todoId, { userId, title, completed }); response.body = { msg: "Todo updated" };};
updateTodo 處理器用於更新指定的 Todo,在執行更新前,會判斷指定的 Todo 是否存在,當存在的時候才會執行更新操作。
handlers/deleteTodo.ts
import { Response, RouteParams } from "https://deno.land/x/oak/mod.ts";import { deleteTodo, getTodo } from "../services/todos.ts";export default async ({ params, response}: { params: RouteParams; response: Response;}) => { const todoId = params.id; if (!todoId) { response.status = 400; response.body = { msg: "Invalid todo id" }; return; } const foundTodo = await getTodo(todoId); if (!foundTodo) { response.status = 404; response.body = { msg: `Todo with ID ${todoId} not found` }; return; } await deleteTodo(todoId); response.body = { msg: "Todo deleted" };};
deleteTodo 處理器用於刪除指定的 Todo,在執行刪除前會校驗 todoId 是否為空和對應 Todo 是否存在。
除了上面已經定義的處理器,我們還需要處理不存在的路由並返回一條錯誤訊息。
handlers/notFound.ts
import { Response } from "https://deno.land/x/oak/mod.ts";export default ({ response }: { response: Response }) => { response.status = 404; response.body = { msg: "Not Found" };};
步驟七:新增服務
在建立 Todo 服務前,我們先來建立兩個小的 helper(輔助)服務。
services/util.ts
import { v4 as uuid } from "https://deno.land/std/uuid/mod.ts";export const createId = () => uuid.generate();
在 util.ts 檔案中,我們使用 Deno 標準庫的 uuid 模組來為新建的 Todo 生成一個唯一的 id。
services/db.ts
import { DB_PATH } from "../config.ts";import { Todo } from "../models/todo.ts";export const fetchData = async (): Promise<Todo[]> => { const data = await Deno.readFile(DB_PATH); const decoder = new TextDecoder(); const decodedData = decoder.decode(data); return JSON.parse(decodedData);};export const persistData = async (data: Todo[]): Promise<void> => { const encoder = new TextEncoder(); await Deno.writeFile(DB_PATH, encoder.encode(JSON.stringify(data)));};
在我們的示例中,db.ts 檔案用於實現資料的管理,資料持久化方式使用的是本地的 JSON 檔案。為了獲取所有的 Todo,我們根據 DB_PATH 設定的路徑,讀取對應的檔案內容。readFile 函式返回一個 Uint8Array 物件,該物件在解析為 JSON 物件之前需要轉換為字串。Uint8Array 和 TextDecoder 都來自核心 JavaScript API。同樣,在儲存資料時,需要先把字串轉換為 Uint8Array。
為了讓大家更好地理解上面表述的內容,我們來分別看一下 Deno 名稱空間下 readFile 和 writeFile 這兩個方法的定義:
1. Deno.readFile
export function readFile(path: string): Promise<Uint8Array>;
Deno.readFile 使用示例:
const decoder = new TextDecoder("utf-8");const data = await Deno.readFile("hello.txt");console.log(decoder.decode(data));
2. Deno.writeFile
export function writeFile( path: string, data: Uint8Array, options?: WriteFileOptions): Promise<void>;
Deno.writeFile 使用示例:
const encoder = new TextEncoder();const data = encoder.encode("Hello world\\n");// overwrite "hello1.txt" or create itawait Deno.writeFile("hello1.txt", data);// only works if "hello2.txt" existsawait Deno.writeFile("hello2.txt", data, {create: false}); // set permissions on new fileawait Deno.writeFile("hello3.txt", data, {mode: 0o777}); // add data to the end of the fileawait Deno.writeFile("hello4.txt", data, {append: true});
接著我們來定義最核心的 todos.ts 服務,該服務用於實現 Todo 的增刪改查。
services/todos.ts
import { fetchData, persistData } from "./db.ts";import { Todo } from "../models/todo.ts";import { createId } from "../services/util.ts";type TodoData = Pick<Todo, "userId" | "title" | "completed">;// 獲取Todo列表export const getTodos = async (): Promise<Todo[]> => { const todos = await fetchData(); return todos.sort((a, b) => a.title.localeCompare(b.title));};// 獲取Todo詳情export const getTodo = async (todoId: string): Promise<Todo | undefined> => { const todos = await fetchData(); return todos.find(({ id }) => id === todoId);};// 新建Todoexport const createTodo = async (todoData: TodoData): Promise<string> => { const todos = await fetchData(); const newTodo: Todo = { ...todoData, id: createId(), }; await persistData([...todos, newTodo]); return newTodo.id;};// 更新Todoexport const updateTodo = async ( todoId: string, todoData: TodoData): Promise<void> => { const todo = await getTodo(todoId); if (!todo) { throw new Error("Todo not found"); } const updatedTodo = { ...todo, ...todoData, }; const todos = await fetchData(); const filteredTodos = todos.filter((todo) => todo.id !== todoId); persistData([...filteredTodos, updatedTodo]);};// 刪除Todoexport const deleteTodo = async (todoId: string): Promise<void> => { const todos = await getTodos(); const filteredTodos = todos.filter((todo) => todo.id !== todoId); persistData(filteredTodos);};
步驟八:新增異常處理中介軟體如果使用者服務出現錯誤,會發生什麼情況?這將可能導致整個應用程式崩潰。為了避免出現這種情況,我們可以在每個處理程式中新增 try/catch 塊,但其實還有一個更好的解決方案,即在所有路由之前新增異常處理中介軟體,在該中介軟體內部來捕獲所有異常。
middlewares/error.ts
import { Response } from "https://deno.land/x/oak/mod.ts";export default async ( { response }: { response: Response }, next: () => Promise<void>) => { try { await next(); } catch (err) { response.status = 500; response.body = { msg: err.message }; }};
步驟九:功能驗證Todo 功能開發完成後,我們可以使用 HTTP 客戶端來進行介面測試,這裡我使用的是 VSCode IDE 下的 REST Client 擴充套件,首先我們在專案根目錄下新建一個 todo.http 檔案,然後複製以下內容:
### 獲取Todo列表GET http://localhost:3000/todos HTTP/1.1### 獲取Todo詳情GET http://localhost:3000/todos/${todoId}### 新增TodoPOST http://localhost:3000/todos HTTP/1.1content-type: application/json{ "userId": 666, "title": "Learn Deno"}### 更新TodoPUT http://localhost:3000/todos/${todoId} HTTP/1.1content-type: application/json{ "userId": 666, "title": "Learn Deno", "completed": true}### 刪除TodoDELETE http://localhost:3000/todos/${todoId} HTTP/1.1
友情提示:需要注意的是 todo.http 檔案中的 ${todoId} 需要替換為實際的 Todo 編號,該編號可以先通過新增 Todo,然後從 db/todos.json 檔案中獲取。
萬事具備只欠東風,接下來就是啟動我們的 Todo 應用了,進入 Todo 專案的根目錄,然後在命令列中執行 deno run -A index.ts 命令:
$ deno run -A index.tsListening on 3000...
在以上命令中的 -A 標誌,與 --allow-all 標誌是等價的,表示允許所有許可權。
-A, --allow-all Allow all permissions --allow-env Allow environment access --allow-hrtime Allow high resolution time measurement --allow-net=<allow-net> Allow network access --allow-plugin Allow loading plugins --allow-read=<allow-read> Allow file system read access --allow-run Allow running subprocesses --allow-write=<allow-write> Allow file system write access
可能有一些讀者還沒使用過 REST Client 擴充套件,這裡我來演示一下如何新增 Todo:
deno-add-todo
從返回的 HTTP 響應報文,我們可以知道 Learn Deno 的 Todo 已經新增成功了,安全起見讓我們來開啟 Todo 根目錄下的 db 目錄中的 todos.json 檔案,驗證一下是否 “入庫” 成功,具體如下圖所示:
todos-json
從圖可知 Learn Deno 的 Todo 的確新增成功了,對於其他的介面有興趣的讀者可以自行測試一下。
Deno 實戰之 Todo 專案原始碼:https://github.com/semlinker/deno-todos-api