【Deno】600- 了不起的 Deno 實戰教程

創建了一個 “重學TypeScript” 的微信羣,想加羣的小夥伴,加我微信 "semlinker",備註重學TS。

已出 TypeScript 系列教程 33 篇,歡迎感興趣的小夥伴來閱讀與交流。

對 Deno 還不瞭解的讀者,建議先閱讀本人 “了不起的 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();

// Logger
app.use(async (ctx, next) => {
  await next();
  const rt = ctx.response.headers.get("X-Response-Time");
  console.log(`${ctx.request.method} ${ctx.request.url} - ${rt}`);
});

// Timing
app.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.ts
GET http://localhost:8000/ - 0ms
GET 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,在執行新增操作前,會驗證是否缺少 userIdtitle 必填項。


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 命名空間下 readFilewriteFile 這兩個方法的定義:

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 it
await Deno.writeFile("hello1.txt", data);
// only works if "hello2.txt" exists
await Deno.writeFile("hello2.txt", data, {create: false});  
// set permissions on new file
await Deno.writeFile("hello3.txt", data, {mode: 0o777});  
// add data to the end of the file
await 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);
};

// 新建Todo
export const createTodo = async (todoData: TodoData): Promise<string> => {
  const todos = await fetchData();

  const newTodo: Todo = {
    ...todoData,
    id: createId(),
  };

  await persistData([...todos, newTodo]);

  return newTodo.id;
};

// 更新Todo
export 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]);
};

// 刪除Todo
export 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}

### 新增Todo

POST http://localhost:3000/todos HTTP/1.1
content-type: application/json

{
    "userId": 666,
    "title": "Learn Deno"
}

### 更新Todo
PUT http://localhost:3000/todos/${todoId} HTTP/1.1
content-type: application/json

{
    "userId": 666,
    "title": "Learn Deno",
    "completed": true
}

### 刪除Todo
DELETE  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.ts
Listening 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

三、參考資源

  • Github - oak

  • the-deno-handbook

  • write-a-small-api-using-deno

往期精彩回顧

 

在 TS 中如何減少重複代碼

在 TS 中如何減少重複代碼

 

一文讀懂 TS 中 Object, object, {} 類型之間的區別

一文讀懂 TS 中 Object, object, {} 類型之間的區別

 

遇到這些 TS 問題你會頭暈麼?

遇到這些 TS 問題你會頭暈麼?

聚焦全棧,專注分享 Angular、TypeScript、Node.js 、Spring 技術棧等全棧乾貨。

回覆 0 進入重學TypeScript學習羣

回覆 1 獲取全棧修仙之路博客地址

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章