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


建立了一個 「重學TypeScript」 的微信羣,想加羣的小夥伴,加我微信 "semlinker",備註重學TS。前端

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

對 Deno 還不瞭解的讀者,建議先閱讀本人 「了不得的 Deno 入門教程」 這篇文章。
了不得的 Deno 入門教程


1、Oak 簡介
github

相信接觸過 Node.js 的讀者對 Express、Hapi、Koa 這些 Web 應用開發框架都不會陌生,在 Deno 平臺中若是你也想作 Web 應用開發,能夠考慮直接使用如下現成的框架: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.express

This middleware framework is inspired by Koa and middleware router inspired by koa-router.json

很顯然 Oak 的的靈感來自於 Koa,而路由中間件的靈感來源於 koa-router 這個庫。若是你之前使用過 Koa 的話,相信你會很容易上手 Oak。不信的話,咱們來看個示例:api

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。

2、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

3、參考資源

  • 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 獲取全棧修仙之路博客地址

本文分享自微信公衆號 - 前端自習課(FE-study)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。

相關文章
相關標籤/搜索